Merge pull request #67 from richardr1126/v1.1.0

v1.1.0
This commit is contained in:
Richard Roberson 2025-11-22 15:38:13 -07:00 committed by GitHub
commit 1c6f628c10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2404 additions and 906 deletions

View file

@ -1,8 +1,18 @@
# Use Node.js slim image
FROM node:current-alpine
# Stage 1: build whisper.cpp (no model download the app handles that)
FROM alpine:3.20 AS whisper-builder
# Add ffmpeg and libreoffice using Alpine package manager
RUN apk add --no-cache ffmpeg libreoffice-writer
RUN apk add --no-cache git cmake build-base
WORKDIR /opt
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp.git && \
cd whisper.cpp && \
cmake -B build && \
cmake --build build -j --config Release
# Stage 2: build the Next.js app
FROM node:lts-alpine AS app-builder
# Install pnpm globally
RUN npm install -g pnpm
@ -23,8 +33,34 @@ COPY . .
RUN pnpm exec next telemetry disable
RUN pnpm build
# Stage 3: minimal runtime image
FROM node:current-alpine AS runner
# Add runtime OS dependencies:
# - ffmpeg: required for audiobook export and word-by-word alignment (/api/whisper)
# - libreoffice-writer: required for DOCX → PDF conversion
RUN apk add --no-cache ffmpeg libreoffice-writer
# Install pnpm globally for running the app
RUN npm install -g pnpm
# App runtime directory
WORKDIR /app
# Copy built app and dependencies from the builder stage
COPY --from=app-builder /app ./
# Copy the compiled whisper.cpp build output into the runtime image
# (includes whisper-cli and its shared libraries, e.g. libwhisper.so, libggml.so)
COPY --from=whisper-builder /opt/whisper.cpp/build /opt/whisper.cpp/build
# Point the app at the compiled whisper-cli binary and ensure its libs are discoverable
ENV WHISPER_CPP_BIN=/opt/whisper.cpp/build/bin/whisper-cli
ENV LD_LIBRARY_PATH=/opt/whisper.cpp/build
# Expose the port the app runs on
EXPOSE 3003
# Start the application
CMD ["pnpm", "start"]
CMD ["pnpm", "start"]

View file

@ -11,8 +11,6 @@
OpenReader WebUI is an open source text to speech document reader web app built using Next.js, offering a TTS read along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**. It supports multiple TTS providers including OpenAI, Deepinfra, and custom OpenAI-compatible endpoints like [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI)
- 🧠 *(New)* **Smart Sentence-Aware Narration** merges sentences across pages/chapters for smoother TTS
- 🎧 *(New)* **Reliable Audiobook Export** in **m4b/mp3**, with resumable, chapter-based export and regeneration
- 🎯 *(New)* **Multi-Provider TTS Support**
- [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI): Supporting multi-voice combinations (like `af_heart+af_bella`)
- [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI)
@ -20,56 +18,18 @@ OpenReader WebUI is an open source text to speech document reader web app built
- **Cloud TTS Providers (requiring API keys)**
- [**Deepinfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M + models with support for cloned voices and more
- [**OpenAI API ($$)**](https://platform.openai.com/docs/pricing#transcription-and-speech): tts-1, tts-1-hd, and gpt-4o-mini-tts w/ instructions
- 🚀 *(New)* **Optimized Next.js TTS Proxy** with audio caching and optimized repeat playback
- 💾 *(Updated)* **Local-First Architecture** stores documents and more in-browser with Dexie.js
- 📖 *(Updated)* **Read Along Experience** providing real-time text highlighting during playback (PDF/EPUB)
- *(New)* **Word-by-word** highlighting uses word-by-word timestamps generated server-side with [*whisper.cpp*](https://github.com/ggml-org/whisper.cpp) (optional)
- 🧠 *(New)* **Smart Sentence-Aware Narration** merges sentences across pages/chapters for smoother TTS
- 🎧 *(New)* **Reliable Audiobook Export** in **m4b/mp3**, with resumable, chapter-based export and regeneration
- 🚀 *(New)* **Optimized Next.js TTS Proxy** with audio caching and optimized repeat playback
- 💾 **Local-First Architecture** stores documents and more in-browser with Dexie.js
- 🛜 **Optional Server-side documents** using backend `/docstore` for all users
- 🎨 **Customizable Experience**
- 🎨 Multiple app theme options
- ⚙️ Various TTS and document handling settings
- And more ...
<details>
<summary>
### 🆕 What's New in v1.0.0
</summary>
- 🧠 **Smart sentence continuation**
- Improved NLP handling of complex structures and quoted dialogue provides more natural sentence boundaries and a smoother audio-text flow.
- EPUB and PDF playback now use smarter sentence splitting and continuation metadata so sentences that cross page/chapter boundaries are merged before hitting the TTS API.
- This yields more natural narration and fewer awkward pauses when a sentence spans multiple pages or EPUB spine items.
- 📄 **Modernized PDF text highlighting pipeline**
- Real-time PDF text highlighting is now offloaded to a dedicated Web Worker so scrolling and playback controls remain responsive during narration.
- A new overlay-based highlighting system draws independent highlight layers on top of the PDF, avoiding interference with the underlying text layer.
- Upgraded fuzzy matching with Dice-based similarity improves the accuracy of mapping spoken words to on-screen text.
- A new per-device setting lets you enable or disable real-time PDF highlighting during playback for a more tailored reading experience.
- 🎧 **Chapter/page-based audiobook export with resume & regeneration**
- Per-chapter/per-page generation to disk with persistent `bookId`
- Resumable generation (can cancel and continue later)
- Per-chapter regeneration & deletion
- Final combined **M4B** or **MP3** download with embedded chapter metadata.
- 💾 **Dexie-backed local storage & sync**
- All document types (PDF, EPUB, TXT/MD-as-HTML) and config are stored via a unified Dexie layer on top of IndexedDB.
- Document lists use live Dexie queries (no manual refresh needed), and server sync now correctly includes text/markdown documents as part of the library backup.
- 🗣️ **Kokoro multi-voice selection & utilities**
- Kokoro models now support multi-voice combination, with provider-aware limits and helpers (not supported on OpenAI or Deepinfra)
- ⚡ **Faster, more efficient TTS backend proxy**
- In-memory **LRU caching** for audio responses with configurable size/TTL
- **ETag** support (`304` on cache hits) + `X-Cache` headers (`HIT` / `MISS` / `INFLIGHT`)
- 📄 **More robust DOCX → PDF conversion**
- DOCX conversion now uses isolated per-job LibreOffice profiles and temp directories, polls for a stable output file size, and aggressively cleans up temp files.
- This reduces cross-job interference and flakiness when converting multiple DOCX files in parallel.
- ♿ **Accessibility & layout improvements**
- Dialogs and folder toggles expose proper roles and ARIA attributes.
- PDF/EPUB/HTML readers use a full-height app shell with a sticky bottom TTS bar, improved scrollbars, and refined focus styles.
- ✅ **End-to-end Playwright test suite with TTS mocks**
- Deterministic TTS responses in tests via a reusable Playwright route mock.
- Coverage for accessibility, upload, navigation, folder management, deletion flows, audiobook generation/export and playback across all document types.
</details>
## 🐳 Docker Quick Start
### Prerequisites
@ -194,6 +154,20 @@ Optionally required for different features:
```bash
brew install libreoffice
```
- [whisper.cpp](https://github.com/ggml-org/whisper.cpp) (optional, required for word-by-word highlighting)
```bash
# clone and build whisper.cpp (no model download needed OpenReader handles that)
git clone https://github.com/ggml-org/whisper.cpp.git
cd whisper.cpp
cmake -B build
cmake --build build -j --config Release
# point OpenReader to the compiled whisper-cli binary
echo WHISPER_CPP_BIN=\"$(pwd)/build/bin/whisper-cli\"
```
> **Note:** The `WHISPER_CPP_BIN` path should be set in your `.env` file for OpenReader to use word-by-word highlighting features.
### Steps
1. Clone the repository:

View file

@ -1,6 +1,6 @@
{
"name": "openreader-webui",
"version": "v1.0.1",
"version": "v1.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 3003",

View file

@ -1,15 +1,16 @@
import { NextRequest, NextResponse } from 'next/server';
import { spawn } from 'child_process';
import { writeFile, readFile, mkdir, unlink, readdir } from 'fs/promises';
import { writeFile, readFile, mkdir, unlink, readdir, rm } from 'fs/promises';
import { existsSync, createReadStream } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
interface ConversionRequest {
chapterTitle: string;
buffer: number[];
buffer: TTSAudioBytes;
bookId?: string;
format?: 'mp3' | 'm4b';
format?: TTSAudiobookFormat;
chapterIndex?: number;
}
@ -206,9 +207,12 @@ export async function POST(request: NextRequest) {
await unlink(inputPath).catch(console.error);
return NextResponse.json({
index: chapterIndex,
title: data.chapterTitle,
duration,
status: 'completed' as const,
bookId,
chapterIndex,
duration
format
});
} catch (error) {
@ -229,7 +233,7 @@ export async function POST(request: NextRequest) {
export async function GET(request: NextRequest) {
try {
const bookId = request.nextUrl.searchParams.get('bookId');
const requestedFormat = request.nextUrl.searchParams.get('format') as 'mp3' | 'm4b' | null;
const requestedFormat = request.nextUrl.searchParams.get('format') as TTSAudiobookFormat | null;
if (!bookId) {
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
}
@ -378,4 +382,31 @@ function streamFile(filePath: string, format: string) {
'Cache-Control': 'no-cache',
},
});
}
}
export async function DELETE(request: NextRequest) {
try {
const bookId = request.nextUrl.searchParams.get('bookId');
if (!bookId) {
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
}
const docstoreDir = join(process.cwd(), 'docstore');
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
// If directory doesn't exist, consider it already reset
if (!existsSync(intermediateDir)) {
return NextResponse.json({ success: true, existed: false });
}
// Recursively delete the entire audiobook directory
await rm(intermediateDir, { recursive: true, force: true });
return NextResponse.json({ success: true, existed: true });
} catch (error) {
console.error('Error resetting audiobook:', error);
return NextResponse.json(
{ error: 'Failed to reset audiobook' },
{ status: 500 }
);
}
}

View file

@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { readdir, readFile, rm } from 'fs/promises';
import { readdir, readFile } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import type { TTSAudiobookFormat } from '@/types/tts';
export async function GET(request: NextRequest) {
try {
@ -26,7 +27,7 @@ export async function GET(request: NextRequest) {
duration?: number;
status: 'completed' | 'error';
bookId: string;
format?: 'mp3' | 'm4b';
format?: TTSAudiobookFormat;
}> = [];
for (const metaFile of metaFiles) {
@ -68,31 +69,3 @@ export async function GET(request: NextRequest) {
);
}
}
export async function DELETE(request: NextRequest) {
try {
const bookId = request.nextUrl.searchParams.get('bookId');
if (!bookId) {
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
}
const docstoreDir = join(process.cwd(), 'docstore');
const intermediateDir = join(docstoreDir, `${bookId}-audiobook`);
// If directory doesn't exist, consider it already reset
if (!existsSync(intermediateDir)) {
return NextResponse.json({ success: true, existed: false });
}
// Recursively delete the entire audiobook directory
await rm(intermediateDir, { recursive: true, force: true });
return NextResponse.json({ success: true, existed: true });
} catch (error) {
console.error('Error resetting audiobook:', error);
return NextResponse.json(
{ error: 'Failed to reset audiobook' },
{ status: 500 }
);
}
}

View file

@ -4,7 +4,8 @@ import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs';
import { isKokoroModel } from '@/utils/voice';
import { LRUCache } from 'lru-cache';
import { createHash } from 'crypto';
import type { TTSRequestPayload, TTSError } from '@/types/tts';
import type { TTSRequestPayload } from '@/types/client';
import type { TTSError, TTSAudioBuffer } from '@/types/tts';
export const runtime = 'nodejs';
@ -13,7 +14,7 @@ type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
voice: SpeechCreateParams['voice'] | CustomVoice;
instructions?: string;
};
type AudioBufferValue = ArrayBuffer;
type AudioBufferValue = TTSAudioBuffer;
const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB
const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes
@ -25,7 +26,7 @@ const ttsAudioCache = new LRUCache<string, AudioBufferValue>({
});
type InflightEntry = {
promise: Promise<ArrayBuffer>;
promise: Promise<TTSAudioBuffer>;
controller: AbortController;
consumers: number;
};
@ -40,7 +41,7 @@ async function fetchTTSBufferWithRetry(
openai: OpenAI,
createParams: ExtendedSpeechParams,
signal: AbortSignal
): Promise<ArrayBuffer> {
): Promise<TTSAudioBuffer> {
let attempt = 0;
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
@ -135,7 +136,7 @@ export async function POST(req: NextRequest) {
voice: normalizedVoice,
input: text,
speed: speed,
response_format: format === 'aac' ? 'aac' : 'mp3',
response_format: format,
};
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
@ -143,7 +144,7 @@ export async function POST(req: NextRequest) {
}
// Compute cache key and check LRU before making provider call
const contentType = format === 'aac' ? 'audio/aac' : 'audio/mpeg';
const contentType = 'audio/mpeg';
// Preserve voice string as-is for cache key (no weight stripping)
const voiceForKey = typeof createParams.voice === 'string'
@ -245,7 +246,7 @@ export async function POST(req: NextRequest) {
};
req.signal.addEventListener('abort', onAbort, { once: true });
let buffer: ArrayBuffer;
let buffer: TTSAudioBuffer;
try {
buffer = await entry.promise;
} finally {
@ -280,4 +281,4 @@ export async function POST(req: NextRequest) {
{ status: 500 }
);
}
}
}

View file

@ -0,0 +1,450 @@
import { NextRequest, NextResponse } from 'next/server';
import { createHash, randomUUID } from 'crypto';
import { mkdtemp, writeFile, rm, access, mkdir, readFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { spawn } from 'child_process';
import type { TTSSentenceAlignment, TTSAudioBytes, TTSAudioBuffer } from '@/types/tts';
import { preprocessSentenceForAudio } from '@/lib/nlp';
export const runtime = 'nodejs';
interface WhisperRequestBody {
text: string;
audio: TTSAudioBytes; // raw bytes from Uint8Array
lang?: string;
}
interface WhisperAlignmentOptions {
engine?: 'whisper.cpp';
lang?: string;
}
interface WhisperWord {
start: number;
end: number;
word: string;
}
// Simple in-memory cache keyed by a hash of text+lang+audio length
const alignmentCache = new Map<string, TTSSentenceAlignment[]>();
// Model management using a fixed tiny.en GGML model stored under docstore/model
const MODEL_NAME = 'ggml-tiny.en.bin';
const MODEL_URL =
'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin';
const DOCSTORE_DIR = join(process.cwd(), 'docstore');
const MODEL_DIR = join(DOCSTORE_DIR, 'model');
const MODEL_PATH = join(MODEL_DIR, MODEL_NAME);
const modelReadyPromises = new Map<string, Promise<void>>();
async function ensureModelAvailable(): Promise<void> {
// Fast path: model already present
try {
await access(MODEL_PATH);
return;
} catch {
// fall through to download
}
const existing = modelReadyPromises.get(MODEL_PATH);
if (existing) return existing;
const promise = (async () => {
try {
await access(MODEL_PATH);
return;
} catch {
// still missing
}
await mkdir(MODEL_DIR, { recursive: true });
const res = await fetch(MODEL_URL);
if (!res.ok) {
throw new Error(
`Failed to download Whisper model from ${MODEL_URL}: ${res.status} ${res.statusText}`
);
}
const arrayBuffer = await res.arrayBuffer();
await writeFile(MODEL_PATH, Buffer.from(arrayBuffer));
})();
modelReadyPromises.set(MODEL_PATH, promise);
return promise;
}
async function runWhisperCpp(
wavPath: string,
opts: WhisperAlignmentOptions
): Promise<WhisperWord[]> {
const binary = process.env.WHISPER_CPP_BIN;
if (!binary) {
throw new Error(
'Whisper.cpp binary path not configured. Set WHISPER_CPP_BIN to the compiled binary.'
);
}
await ensureModelAvailable();
return new Promise((resolve, reject) => {
const jsonBase = `${wavPath}.json_out`;
const jsonPath = `${jsonBase}.json`;
// Request full JSON output (including token-level details when available)
// and ask whisper-cli not to print anything to stdout.
const args = [
'-m',
MODEL_PATH,
'-f',
wavPath,
'-of',
jsonBase,
'-ojf',
'-np',
];
if (opts.lang) {
args.push('-l', opts.lang);
}
const child = spawn(binary, args);
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('error', (err) => {
reject(err);
});
child.on('close', (code) => {
if (code !== 0) {
console.error('whisper-cli exited with error', {
code,
wavPath,
stdout: stdout.slice(0, 500),
stderr: stderr.slice(0, 500),
});
return reject(
new Error(
`whisper.cpp exited with code ${code}: ${stderr || stdout}`
)
);
}
readFile(jsonPath, 'utf-8')
.then((content: string) => {
const words: WhisperWord[] = [];
const parsed = JSON.parse(content) as {
transcription?: Array<{
text?: string;
timestamps?: { from?: string; to?: string };
offsets?: { from?: number; to?: number };
tokens?: Array<{
text?: string;
timestamps?: { from?: string; to?: string };
offsets?: { from?: number; to?: number };
}>;
}>;
};
const transcription = parsed.transcription;
const parseTimecode = (value?: string): number | null => {
if (!value) return null;
const m = value.match(/(\d+):(\d+):(\d+),(\d+)/);
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
const s = Number(m[3]);
const ms = Number(m[4]);
if (
Number.isNaN(h) ||
Number.isNaN(min) ||
Number.isNaN(s) ||
Number.isNaN(ms)
) {
return null;
}
return h * 3600 + min * 60 + s + ms / 1000;
};
if (Array.isArray(transcription)) {
for (const seg of transcription) {
const segText = (seg.text || '').trim();
const segStartSecFromTs = parseTimecode(
seg.timestamps?.from
);
const segEndSecFromTs = parseTimecode(seg.timestamps?.to);
const segStartSecFromMs =
typeof seg.offsets?.from === 'number'
? seg.offsets.from / 1000
: null;
const segEndSecFromMs =
typeof seg.offsets?.to === 'number'
? seg.offsets.to / 1000
: null;
const segStartSec =
segStartSecFromTs ??
segStartSecFromMs ??
0;
const segEndSec =
segEndSecFromTs ??
segEndSecFromMs ??
segStartSec;
const tokens = Array.isArray(seg.tokens)
? seg.tokens
: [];
if (tokens.length > 0) {
for (const token of tokens) {
const rawText = token.text || '';
const tokenText = rawText.trim();
// Skip special markers like [_BEG_]
if (!tokenText || /^\[.*\]$/.test(tokenText)) continue;
const tokStartSecFromTs = parseTimecode(
token.timestamps?.from
);
const tokEndSecFromTs = parseTimecode(
token.timestamps?.to
);
const tokStartSecFromMs =
typeof token.offsets?.from === 'number'
? token.offsets.from / 1000
: null;
const tokEndSecFromMs =
typeof token.offsets?.to === 'number'
? token.offsets.to / 1000
: null;
const startSec =
tokStartSecFromTs ??
tokStartSecFromMs ??
segStartSec;
const endSec =
tokEndSecFromTs ??
tokEndSecFromMs ??
segEndSec;
words.push({
word: tokenText,
start: startSec,
end: endSec,
});
}
} else if (segText) {
// Fallback: no token list, approximate per-word timing within the segment
const segTokens = segText.split(/\s+/).filter(Boolean);
if (segTokens.length) {
const totalDur = Math.max(segEndSec - segStartSec, 0);
const step =
segTokens.length > 0
? totalDur / segTokens.length
: 0;
segTokens.forEach((token, index) => {
const wStart =
step > 0
? segStartSec + step * index
: segStartSec;
const wEnd =
step > 0
? index === segTokens.length - 1
? segEndSec
: segStartSec + step * (index + 1)
: segEndSec;
words.push({
word: token,
start: wStart,
end: wEnd,
});
});
}
}
}
}
resolve(words);
})
.catch((err: unknown) => {
reject(err);
});
});
});
}
function mapWordsToSentenceOffsets(
sentence: string,
words: WhisperWord[]
): TTSSentenceAlignment {
const normalizedSentence = preprocessSentenceForAudio(sentence);
let cursor = 0;
const alignedWords = words.map((w) => {
const token = w.word.trim();
if (!token) {
return {
text: '',
startSec: w.start,
endSec: w.end,
charStart: cursor,
charEnd: cursor,
};
}
const idx = normalizedSentence
.toLowerCase()
.indexOf(token.toLowerCase(), cursor);
const start =
idx !== -1
? idx
: cursor;
const end = start + token.length;
cursor = end;
return {
text: token,
startSec: w.start,
endSec: w.end,
charStart: start,
charEnd: end,
};
});
return {
sentence,
sentenceIndex: 0,
words: alignedWords.filter((w) => w.text.length > 0),
};
}
async function alignAudioWithText(
audioBuffer: TTSAudioBuffer,
text: string,
cacheKey?: string,
opts: WhisperAlignmentOptions = {}
): Promise<TTSSentenceAlignment[]> {
if (!text.trim()) {
return [];
}
if (cacheKey && alignmentCache.has(cacheKey)) {
return alignmentCache.get(cacheKey)!;
}
const tmpBase = await mkdtemp(join(tmpdir(), 'openreader-whisper-'));
const inputPath = join(tmpBase, `${randomUUID()}-input.bin`);
const wavPath = join(tmpBase, `${randomUUID()}-input.wav`);
try {
await writeFile(inputPath, Buffer.from(new Uint8Array(audioBuffer)));
await new Promise<void>((resolve, reject) => {
const ffmpeg = spawn('ffmpeg', [
'-y',
'-i',
inputPath,
'-ar',
'16000',
'-ac',
'1',
wavPath,
]);
let stderr = '';
ffmpeg.stderr.on('data', (data) => {
stderr += data.toString();
});
ffmpeg.on('error', (err) => {
reject(err);
});
ffmpeg.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(
new Error(`ffmpeg failed with code ${code}: ${stderr}`)
);
}
});
});
const words = await runWhisperCpp(wavPath, opts);
const alignment = mapWordsToSentenceOffsets(text, words);
const result: TTSSentenceAlignment[] = [alignment];
if (cacheKey) {
alignmentCache.set(cacheKey, result);
}
return result;
} finally {
try {
await rm(tmpBase, { recursive: true, force: true });
} catch {
// ignore
}
}
}
function makeCacheKey(input: WhisperRequestBody) {
const hash = createHash('sha256')
.update(
JSON.stringify({
text: input.text,
lang: input.lang || '',
audioLen: input.audio?.length || 0,
})
)
.digest('hex');
return hash;
}
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as WhisperRequestBody;
const { text, audio, lang } = body;
if (!text || !audio || !Array.isArray(audio)) {
return NextResponse.json(
{ error: 'Missing text or audio in request body' },
{ status: 400 }
);
}
const cacheKey = makeCacheKey(body);
const audioBuffer = new Uint8Array(audio).buffer;
const alignments: TTSSentenceAlignment[] = await alignAudioWithText(
audioBuffer,
text,
cacheKey,
{ engine: 'whisper.cpp', lang }
);
return NextResponse.json({ alignments }, { status: 200 });
} catch (error) {
console.error('Error in whisper route:', error);
return NextResponse.json(
{
error: 'WHISPER_ALIGNMENT_FAILED',
message: 'Failed to compute word-level alignment',
},
{ status: 500 }
);
}
}

View file

@ -14,6 +14,7 @@ import TTSPlayer from '@/components/player/TTSPlayer';
import { ZoomControl } from '@/components/ZoomControl';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { DownloadIcon } from '@/components/icons/Icons';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -86,8 +87,8 @@ export default function EPUBPage() {
const handleGenerateAudiobook = useCallback(async (
onProgress: (progress: number) => void,
signal: AbortSignal,
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
format: 'mp3' | 'm4b'
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
format: TTSAudiobookFormat
) => {
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
}, [createEPUBAudioBook, id]);
@ -95,7 +96,7 @@ export default function EPUBPage() {
const handleRegenerateChapter = useCallback(async (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
format: TTSAudiobookFormat,
signal: AbortSignal
) => {
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
@ -190,4 +191,4 @@ export default function EPUBPage() {
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
</>
);
}
}

View file

@ -1,7 +1,7 @@
import { HomeContent } from '@/components/HomeContent';
import { SettingsModal } from '@/components/SettingsModal';
// Home page redesigned for fullscreen layout: hero + document area.
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
export default function Home() {
return (
@ -10,9 +10,9 @@ export default function Home() {
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
<div className="max-w-5xl mx-auto">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight mb-2 text-foreground">OpenReader WebUI</h1>
<p className="text-sm leading-relaxed max-w-prose text-foreground">
Bring your own text-to-speech API.
<span className="block font-medium">Read & listen to PDF, EPUB & HTML documents with high quality voices.</span>
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
<span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span>
</p>
</div>
</section>

View file

@ -12,6 +12,7 @@ import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
import { Header } from '@/components/Header';
import { ZoomControl } from '@/components/ZoomControl';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import TTSPlayer from '@/components/player/TTSPlayer';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -80,8 +81,8 @@ export default function PDFViewerPage() {
const handleGenerateAudiobook = useCallback(async (
onProgress: (progress: number) => void,
signal: AbortSignal,
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
format: 'mp3' | 'm4b'
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
format: TTSAudiobookFormat
) => {
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
}, [createPDFAudioBook, id]);
@ -89,7 +90,7 @@ export default function PDFViewerPage() {
const handleRegenerateChapter = useCallback(async (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
format: TTSAudiobookFormat,
signal: AbortSignal
) => {
return regeneratePDFChapter(chapterIndex, bookId, format, signal);

View file

@ -9,7 +9,14 @@ import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIco
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { LoadingSpinner } from '@/components/Spinner';
import { useConfig } from '@/contexts/ConfigContext';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import {
getAudiobookStatus,
deleteAudiobookChapter,
deleteAudiobook,
downloadAudiobookChapter,
downloadAudiobook
} from '@/lib/client';
interface AudiobookExportModalProps {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
@ -19,12 +26,12 @@ interface AudiobookExportModalProps {
onProgress: (progress: number) => void,
signal: AbortSignal,
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
format: 'mp3' | 'm4b'
format: TTSAudiobookFormat
) => Promise<string>; // Returns bookId
onRegenerateChapter?: (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
format: TTSAudiobookFormat,
signal: AbortSignal
) => Promise<TTSAudiobookChapter>;
}
@ -46,7 +53,7 @@ export function AudiobookExportModal({
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
const [currentChapter, setCurrentChapter] = useState<string>('');
const [format, setFormat] = useState<'mp3' | 'm4b'>('m4b');
const [format, setFormat] = useState<TTSAudiobookFormat>('m4b');
const [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
@ -61,26 +68,23 @@ export function AudiobookExportModal({
setIsLoadingExisting(true);
}
try {
const response = await fetch(`/api/audio/convert/chapters?bookId=${documentId}`);
if (response.ok) {
const data = await response.json();
if (data.exists && data.chapters.length > 0) {
setChapters(data.chapters);
setBookId(data.bookId);
// Set format from existing chapters - this ensures the format matches what was actually generated
if (data.chapters[0]?.format) {
const detectedFormat = data.chapters[0].format as 'mp3' | 'm4b';
setFormat(detectedFormat);
}
// If we have a complete audiobook, we're done
if (data.hasComplete) {
setProgress(100);
}
} else {
// If nothing exists, clear chapters/bookId to reflect current state
setChapters([]);
setBookId(null);
const data = await getAudiobookStatus(documentId);
if (data.exists && data.chapters.length > 0) {
setChapters(data.chapters);
setBookId(data.bookId);
// Set format from existing chapters - this ensures the format matches what was actually generated
if (data.chapters[0]?.format) {
const detectedFormat = data.chapters[0].format as TTSAudiobookFormat;
setFormat(detectedFormat);
}
// If we have a complete audiobook, we're done
if (data.hasComplete) {
setProgress(100);
}
} else {
// If nothing exists, clear chapters/bookId to reflect current state
setChapters([]);
setBookId(null);
}
} catch (error) {
console.error('Error fetching existing chapters:', error);
@ -241,12 +245,7 @@ export function AudiobookExportModal({
const performDeleteChapter = useCallback(async () => {
if (!bookId || !pendingDeleteChapter) return;
try {
const response = await fetch(`/api/audio/convert/chapter?bookId=${bookId}&chapterIndex=${pendingDeleteChapter.index}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Delete failed');
}
await deleteAudiobookChapter(bookId, pendingDeleteChapter.index);
setChapters(prev => prev.filter(c => c.index !== pendingDeleteChapter.index));
await fetchExistingChapters(true);
} catch (error) {
@ -261,10 +260,7 @@ export function AudiobookExportModal({
const targetBookId = bookId || documentId;
if (!targetBookId) return;
try {
const resp = await fetch(`/api/audio/convert/chapters?bookId=${targetBookId}`, { method: 'DELETE' });
if (!resp.ok) {
throw new Error('Reset failed');
}
await deleteAudiobook(targetBookId);
setChapters([]);
setBookId(null);
setProgress(0);
@ -281,10 +277,7 @@ export function AudiobookExportModal({
if (!chapter.bookId) return;
try {
const response = await fetch(`/api/audio/convert/chapter?bookId=${chapter.bookId}&chapterIndex=${chapter.index}`);
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const blob = await downloadAudiobookChapter(chapter.bookId, chapter.index);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@ -306,8 +299,7 @@ export function AudiobookExportModal({
setIsCombining(true);
try {
const response = await fetch(`/api/audio/convert?bookId=${bookId}&format=${format}`);
if (!response.ok) throw new Error('Download failed');
const response = await downloadAudiobook(bookId, format);
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');

View file

@ -0,0 +1,32 @@
'use client';
import { useState } from 'react';
import { CopyIcon, CheckIcon } from '@/components/icons/Icons';
export function CodeBlock({ children }: { children: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative group my-2">
<pre className="bg-background p-3 rounded-md overflow-x-auto text-xs text-left
font-mono text-foreground border border-offbase">
<code>{children}</code>
</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md bg-base hover:bg-offbase hover:text-accent
transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100
hover:scale-[1.05]"
title="Copy to clipboard"
>
{copied ? <CheckIcon className="w-4 h-4" /> : <CopyIcon className="w-4 h-4" />}
</button>
</div>
);
}

View file

@ -8,6 +8,7 @@ import { useEPUB } from '@/contexts/EPUBContext';
import { usePDF } from '@/contexts/PDFContext';
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { useParams } from 'next/navigation';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -35,6 +36,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
updateConfigKey,
pdfHighlightEnabled,
epubHighlightEnabled,
pdfWordHighlightEnabled,
epubWordHighlightEnabled,
} = useConfig();
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
@ -78,8 +81,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
const handleGenerateAudiobook = useCallback(async (
onProgress: (progress: number) => void,
signal: AbortSignal,
onChapterComplete: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
format: 'mp3' | 'm4b'
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
format: TTSAudiobookFormat
) => {
if (epub) {
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
@ -91,7 +94,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
const handleRegenerateChapter = useCallback(async (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
format: TTSAudiobookFormat,
signal: AbortSignal
) => {
if (epub) {
@ -138,16 +141,18 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
{isDev && !html && <div className="space-y-2 mb-4">
{!html && <div className="space-y-2 mb-4">
<Button
type="button"
className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-[1] disabled:hover:bg-accent"
onClick={() => setIsAudiobookModalOpen(true)}
disabled={!isDev}
>
Export Audiobook
Export Audiobook {!isDev && '(requires self-hosted)'}
</Button>
</div>}
@ -324,40 +329,82 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
</span>
</label>
<p className="text-sm text-muted pl-6">
Merge sentences across page or section breaks for smoother TTS.
Merge sentences across page or section breaks
</p>
</div>
)}
{!epub && !html && (
<div className="space-y-1">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={pdfHighlightEnabled}
onChange={(e) => updateConfigKey('pdfHighlightEnabled', e.target.checked)}
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
/>
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
</label>
<p className="text-sm text-muted pl-6">
Show visual highlighting in the PDF viewer while TTS is reading.
</p>
<div className="space-y-2">
<div className="space-y-1">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={pdfHighlightEnabled}
onChange={(e) => updateConfigKey('pdfHighlightEnabled', e.target.checked)}
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
/>
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
</label>
<p className="text-sm text-muted pl-6">
Visual text playback highlighting in the PDF viewer
</p>
</div>
<div className="space-y-1 pl-6">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
disabled={!pdfHighlightEnabled || !isDev}
onChange={(e) =>
updateConfigKey('pdfWordHighlightEnabled', e.target.checked)
}
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="text-sm font-medium text-foreground">
Word-by-word
</span>
</label>
<p className="text-sm text-muted pl-6">
Highlight individual words using audio timestamps generated by whisper.cpp {!isDev && '(requires self-hosted)'}
</p>
</div>
</div>
)}
{epub && (
<div className="space-y-1">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={epubHighlightEnabled}
onChange={(e) => updateConfigKey('epubHighlightEnabled', e.target.checked)}
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
/>
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
</label>
<p className="text-sm text-muted pl-6">
Show visual highlighting in the EPUB viewer while TTS is reading.
</p>
<div className="space-y-2">
<div className="space-y-1">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={epubHighlightEnabled}
onChange={(e) => updateConfigKey('epubHighlightEnabled', e.target.checked)}
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
/>
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
</label>
<p className="text-sm text-muted pl-6">
Visual text playback highlighting in the EPUB viewer
</p>
</div>
<div className="space-y-1 pl-6">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={epubWordHighlightEnabled && epubHighlightEnabled}
disabled={!epubHighlightEnabled || !isDev}
onChange={(e) =>
updateConfigKey('epubWordHighlightEnabled', e.target.checked)
}
className="form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="text-sm font-medium text-foreground">
Word-by-word
</span>
</label>
<p className="text-sm text-muted pl-6">
Highlight individual words using audio timestamps generated by whisper.cpp {!isDev && '(requires self-hosted)'}
</p>
</div>
</div>
)}
{epub && (
@ -369,10 +416,10 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
onChange={(e) => updateConfigKey('epubTheme', e.target.checked)}
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
/>
<span className="text-sm font-medium text-foreground">Use theme (experimental)</span>
<span className="text-sm font-medium text-foreground">Use theme</span>
</label>
<p className="text-sm text-muted pl-6">
Apply the current app theme to the EPUB viewer
Apply the current app theme to the EPUB viewer background and text colors
</p>
</div>
)}

View file

@ -4,6 +4,7 @@ import { useState, useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { UploadIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext';
import { convertDocxToPdf as convertDocxToPdfClient } from '@/lib/client';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -23,19 +24,7 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume
const [error, setError] = useState<string | null>(null);
const convertDocxToPdf = async (file: File): Promise<File> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/documents/docx-to-pdf', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to convert DOCX to PDF');
}
const pdfBlob = await response.blob();
const pdfBlob = await convertDocxToPdfClient(file);
return new File([pdfBlob], file.name.replace(/\.docx$/, '.pdf'), {
type: 'application/pdf',
});

View file

@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { useEffect, useRef, useCallback, useState } from 'react';
import dynamic from 'next/dynamic';
import { useEPUB } from '@/contexts/EPUBContext';
import { useTTS } from '@/contexts/TTSContext';
@ -8,6 +8,7 @@ import { useConfig } from '@/contexts/ConfigContext';
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
import { DotsVerticalIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/icons/Icons';
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
ssr: false,
@ -19,9 +20,12 @@ interface EPUBViewerProps {
}
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
const [isTocOpen, setIsTocOpen] = useState(false);
const {
currDocData,
currDocName,
currDocPage,
currDocPages,
locationRef,
handleLocationChanged,
bookRef,
@ -30,10 +34,18 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
setRendition,
extractPageText,
highlightPattern,
clearHighlights
clearHighlights,
highlightWordIndex,
clearWordHighlights
} = useEPUB();
const { registerLocationChangeHandler, pause, currentSentence } = useTTS();
const { epubTheme } = useConfig();
const {
registerLocationChangeHandler,
pause,
currentSentence,
currentSentenceAlignment,
currentWordIndex
} = useTTS();
const { epubTheme, epubHighlightEnabled, epubWordHighlightEnabled } = useConfig();
const { updateTheme } = useEPUBTheme(epubTheme, renditionRef.current);
const containerRef = useRef<HTMLDivElement>(null);
const { isResizing, setIsResizing, dimensions } = useEPUBResize(containerRef);
@ -70,13 +82,103 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
}
}, [currentSentence, highlightPattern, clearHighlights]);
// Word-level highlight layered on top of the block highlight
useEffect(() => {
if (!epubHighlightEnabled || !epubWordHighlightEnabled) {
clearWordHighlights();
return;
}
if (currentWordIndex === null || currentWordIndex === undefined || currentWordIndex < 0) {
clearWordHighlights();
return;
}
if (!currentSentenceAlignment) {
clearWordHighlights();
return;
}
highlightWordIndex(
currentSentenceAlignment,
currentWordIndex,
currentSentence || ''
);
}, [
currentWordIndex,
currentSentence,
currentSentenceAlignment,
epubHighlightEnabled,
epubWordHighlightEnabled,
clearWordHighlights,
highlightWordIndex
]);
if (!currDocData) {
return <DocumentSkeleton />;
}
return (
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
<div className="flex-1">
<div className="flex items-center justify-between px-2 py-1 border-b border-offbase bg-base text-xs text-muted">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsTocOpen(open => !open)}
className="inline-flex items-center py-1 px-1 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label={isTocOpen ? 'Hide chapters' : 'Show chapters'}
>
<DotsVerticalIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => renditionRef.current?.prev()}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label="Previous section"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
</div>
{currDocPages !== undefined && typeof currDocPage === 'number' && (
<span className="px-2 tabular-nums">
{currDocPage} / {currDocPages}
</span>
)}
<button
type="button"
onClick={() => renditionRef.current?.next()}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out transform hover:scale-[1.09] hover:text-accent"
aria-label="Next section"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
{isTocOpen && tocRef.current && tocRef.current.length > 0 && (
<div className="border-b border-offbase bg-background text-xs overflow-y-auto max-h-64 p-2">
<div className="font-semibold text-muted pb-1">Skip to chapters</div>
<div className="flex flex-wrap gap-1 w-full">
{tocRef.current.map((item, index) => (
<button
key={`${item.href}-${index}`}
type="button"
onClick={() => {
if (item.href) handleLocationChanged(item.href);
setIsTocOpen(false);
}}
className="
px-2 py-1 rounded-md font-medium text-foreground text-center bg-base
hover:bg-offbase hover:text-accent transition-colors duration-150
whitespace-nowrap
flex-1 min-w-[140px]
"
>
{item.label}
</button>
))}
</div>
</div>
)}
<div className="flex-1 min-h-0">
<ReactReader
loadingView={<DocumentSkeleton />}
key={'epub-reader'}
@ -85,8 +187,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
url={currDocData}
title={currDocName}
tocChanged={(_toc) => (tocRef.current = _toc)}
showToc={true}
readerStyles={epubTheme && getThemeStyles() || undefined}
showToc={false}
readerStyles={getThemeStyles(epubTheme)}
getRendition={(_rendition) => {
setRendition(_rendition);
updateTheme();
@ -95,4 +197,4 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
</div>
</div>
);
}
}

View file

@ -1,10 +1,14 @@
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
import { GithubIcon } from '@/components/icons/Icons'
import { CodeBlock } from '@/components/CodeBlock'
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
export function Footer() {
return (
<footer className="m-8 text-sm text-muted">
<div className="flex flex-col items-center space-y-2">
<footer className="m-8 mb-2 text-sm text-muted">
<div className="flex flex-col items-center space-y-4">
<div className="flex flex-wrap sm:flex-nowrap items-center justify-center text-center sm:space-x-3">
<a
href="https://github.com/richardr1126/OpenReader-WebUI"
@ -16,15 +20,16 @@ export function Footer() {
</a>
<span className='w-full sm:w-fit'></span>
<Popover className="flex">
<PopoverButton className="hover:text-foreground transition-colors flex items-center gap-1">
<PopoverButton className="font-bold hover:text-foreground transition-colors outline-none flex items-center gap-1">
Privacy info
</PopoverButton>
<PopoverPanel anchor="top" className="bg-base p-4 rounded-lg shadow-lg w-64">
<p>Documents are uploaded to your local browser cache.</p>
<p className='mt-3'>Each sentence of the document you are viewing is sent to my Kokoro-FastAPI server for audio generation.</p>
<p className='mt-3'>The audio is streamed back to your browser and played in real-time.</p>
<PopoverPanel anchor="top" className="bg-base p-4 rounded-lg shadow-xl border border-offbase z-50">
<p className='max-w-xs'>Documents are uploaded to your local browser cache.</p>
<p className='mt-3 max-w-xs'>Each paragraph of the document you are viewing is sent to Deepinfra for audio generation through a Vercel backend proxy, containing a shared caching pool.</p>
<p className='mt-3 max-w-xs'>The audio is streamed back to your browser and played in real-time.</p>
<p className='mt-3 max-w-xs font-bold'><em>Self-hosting is the recommended way to use this app for a truly secure experience.</em></p>
{/* Vercel analytics disclaimer */}
<p className='mt-3'>This site uses Vercel Analytics to collect anonymous usage data to help improve the service.</p>
<p className='mt-3 max-w-xs'>This site uses Vercel Analytics to collect anonymous usage data to help improve the service.</p>
</PopoverPanel>
</Popover>
<span className='w-full sm:w-fit'></span>
@ -34,7 +39,7 @@ export function Footer() {
href="https://huggingface.co/hexgrad/Kokoro-82M"
target="_blank"
rel="noopener noreferrer"
className="font-bold hover:text-foreground transition-colors"
className="font-bold hover:text-foreground transition-colors underline decoration-dotted underline-offset-4"
>
hexgrad/Kokoro-82M
</a>
@ -43,12 +48,66 @@ export function Footer() {
href="https://deepinfra.com/models?type=text-to-speech"
target="_blank"
rel="noopener noreferrer"
className="font-bold hover:text-foreground transition-colors"
className="font-bold hover:text-foreground transition-colors underline decoration-dotted underline-offset-4"
>
Deepinfra
</a>
</span>
</div>
<div className='font-medium text-center flex items-center justify-center gap-1'>
<span>This is a demo app (</span>
<Popover className="relative">
<PopoverButton className="font-bold hover:text-foreground transition-colors outline-none">
self-host
</PopoverButton>
<PopoverPanel anchor="top" className="bg-base p-6 rounded-xl shadow-2xl border border-offbase w-[90vw] max-w-3xl z-50 backdrop-blur-md flex flex-col gap-4">
<div className="space-y-4 font-medium">
<h3 className="text-lg font-bold text-foreground">Self-Hosting Instructions</h3>
<div>
<p className="mb-2 font-medium">
1. Start the <a href="https://github.com/remsky/Kokoro-FastAPI" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground underline decoration-dotted underline-offset-4">Kokoro-FastAPI</a> container
</p>
<CodeBlock>
{
`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`
}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-foreground font-medium">2. Start OpenReader WebUI container</p>
<CodeBlock>
{
`docker run --name openreader-webui --rm \\
-e API_BASE=http://kokoro-tts:8880/v1 \\
-p 3003:3003 \\
-v openreader_docstore:/app/docstore \\
ghcr.io/richardr1126/openreader-webui:latest`
}
</CodeBlock>
</div>
<p>
Visit <a href="http://localhost:3003" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors underline decoration-dotted underline-offset-4">http://localhost:3003</a> to run the app and set your settings.
{' '}See the <a href="https://github.com/richardr1126/OpenReader-WebUI#readme" target="_blank" rel="noopener noreferrer" className="text-muted hover:text-foreground transition-colors underline decoration-dotted underline-offset-4">README</a> for more details.
</p>
</div>
</PopoverPanel>
</Popover>
<span> for full functionality)</span>
</div>
</div>
</footer>
)

View file

@ -26,11 +26,13 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const { containerWidth } = usePDFResize(containerRef);
// Config context
const { viewType, pdfHighlightEnabled } = useConfig();
const { viewType, pdfHighlightEnabled, pdfWordHighlightEnabled } = useConfig();
// TTS context
const {
currentSentence,
currentWordIndex,
currentSentenceAlignment,
skipToLocation,
} = useTTS();
@ -38,6 +40,8 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
const {
highlightPattern,
clearHighlights,
clearWordHighlights,
highlightWordIndex,
onDocumentLoadSuccess,
currDocData,
currDocPages,
@ -74,6 +78,46 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
};
}, [currDocText, currentSentence, highlightPattern, clearHighlights, pdfHighlightEnabled]);
// Word-level highlight layered on top of the block highlight
useEffect(() => {
if (!pdfHighlightEnabled || !pdfWordHighlightEnabled) {
clearWordHighlights();
return;
}
if (currentWordIndex === null || currentWordIndex === undefined || currentWordIndex < 0) {
clearWordHighlights();
return;
}
const wordEntry =
currentSentenceAlignment &&
currentWordIndex < currentSentenceAlignment.words.length
? currentSentenceAlignment.words[currentWordIndex]
: undefined;
const wordText = wordEntry?.text || null;
if (!wordText) {
clearWordHighlights();
return;
}
highlightWordIndex(
currentSentenceAlignment,
currentWordIndex,
currentSentence || '',
containerRef as RefObject<HTMLDivElement>
);
}, [
currentWordIndex,
currentSentence,
currentSentenceAlignment,
pdfHighlightEnabled,
pdfWordHighlightEnabled,
clearWordHighlights,
highlightWordIndex
]);
// Add page dimensions state
const [pageWidth, setPageWidth] = useState<number>(595); // default A4 width
const [pageHeight, setPageHeight] = useState<number>(842); // default A4 height

View file

@ -28,6 +28,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ProgressPopup } from '@/components/ProgressPopup';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { THEMES } from '@/contexts/ThemeContext';
import { deleteServerDocuments } from '@/lib/client';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
@ -222,12 +223,7 @@ export function SettingsModal() {
const handleClearServer = async () => {
try {
const response = await fetch('/api/documents', {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete server documents');
}
await deleteServerDocuments();
} catch (error) {
console.error('Delete failed:', error);
}
@ -404,7 +400,7 @@ export function SettingsModal() {
type="password"
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or override apikey" : "Using environment variable"}
placeholder={!isDev && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"}
className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
@ -603,7 +599,7 @@ export function SettingsModal() {
<TabPanel className="space-y-4">
{isDev && <div className="space-y-1">
<label className="block text-sm font-medium text-foreground">Document Sync</label>
<label className="block text-sm font-medium text-foreground">Server Document Sync</label>
<div className="flex gap-2">
<Button
onClick={handleLoad}
@ -614,7 +610,7 @@ export function SettingsModal() {
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isLoading ? `Loading... ${Math.round(progress)}%` : 'Load docs from Server'}
{isLoading ? `Loading... ${Math.round(progress)}%` : 'Load'}
</Button>
<Button
onClick={handleSync}
@ -625,13 +621,13 @@ export function SettingsModal() {
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
{isSyncing ? `Saving... ${Math.round(progress)}%` : 'Save local to Server'}
{isSyncing ? `Saving... ${Math.round(progress)}%` : 'Save to server'}
</Button>
</div>
</div>}
<div className="space-y-1 pb-3">
<label className="block text-sm font-medium text-foreground">Bulk Delete</label>
<label className="block text-sm font-medium text-foreground">Delete All</label>
<div className="flex gap-2">
<Button
onClick={() => setShowClearLocalConfirm(true)}
@ -640,7 +636,7 @@ export function SettingsModal() {
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete local docs
Delete local
</Button>
{isDev && <Button
onClick={() => setShowClearServerConfirm(true)}
@ -649,7 +645,7 @@ export function SettingsModal() {
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete server docs
Delete server
</Button>}
</div>
</div>

View file

@ -1,5 +1,3 @@
"use client";
import { useState, DragEvent } from 'react';
import { Button, Transition } from '@headlessui/react';
import { DocumentListItem } from './DocumentListItem';
@ -16,6 +14,7 @@ interface DocumentFolderProps {
onDragStart: (doc: DocumentListDocument) => void;
onDragEnd: () => void;
onDrop: (e: DragEvent, folderId: string) => void;
viewMode: 'list' | 'grid';
}
const ChevronIcon = ({ className = "w-4 h-4" }) => (
@ -39,6 +38,7 @@ export function DocumentFolder({
onDragStart,
onDragEnd,
onDrop,
viewMode,
}: DocumentFolderProps) {
const [isHovering, setIsHovering] = useState(false);
const isDropTarget = isHovering && draggedDoc && !draggedDoc.folderId && draggedDoc.id !== folder.id;
@ -64,7 +64,7 @@ export function DocumentFolder({
if (!draggedDoc || draggedDoc.folderId) return;
onDrop(e, folder.id);
}}
className={`overflow-hidden rounded-md border border-offbase ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
className={`w-full overflow-hidden rounded-md border border-offbase ${isDropTarget ? 'ring-2 ring-accent' : ''}`}
>
<div className='flex flex-row justify-between p-0'>
<div className="w-full">
@ -104,7 +104,7 @@ export function DocumentFolder({
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
leaveTo="transform scale-y-0 opacity-0 max-h-0"
>
<div id={`folder-panel-${folder.id}`} className="space-y-1 origin-top">
<div id={`folder-panel-${folder.id}`} className={`${viewMode === 'grid' ? "flex flex-wrap gap-1" : "space-y-1"} w-full origin-top`}>
{sortedDocuments.map(doc => (
<DocumentListItem
key={`${doc.type}-${doc.id}`}
@ -114,6 +114,7 @@ export function DocumentFolder({
onDragStart={onDragStart}
onDragEnd={onDragEnd}
isDropTarget={false}
viewMode={viewMode}
/>
))}
</div>

View file

@ -55,6 +55,7 @@ export function DocumentList() {
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
const [isInitialized, setIsInitialized] = useState(false);
const [showHint, setShowHint] = useState(true);
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
const {
pdfDocs,
@ -78,6 +79,7 @@ export function DocumentList() {
setFolders(savedState.folders);
setCollapsedFolders(new Set(savedState.collapsedFolders));
setShowHint(savedState.showHint ?? true); // Use saved hint state or default to true
setViewMode(savedState.viewMode ?? 'grid');
}
setIsInitialized(true);
};
@ -92,7 +94,8 @@ export function DocumentList() {
sortDirection,
folders,
collapsedFolders: Array.from(collapsedFolders),
showHint
showHint,
viewMode
};
await saveDocumentListState(state);
};
@ -100,7 +103,7 @@ export function DocumentList() {
if (isInitialized) { // Prevents saving empty state on first render or back navigation
saveState();
}
}, [sortBy, sortDirection, folders, collapsedFolders, showHint, isInitialized]);
}, [sortBy, sortDirection, folders, collapsedFolders, showHint, viewMode, isInitialized]);
const allDocuments: DocumentListDocument[] = [
...pdfDocs.map(doc => ({
@ -315,6 +318,8 @@ export function DocumentList() {
sortDirection={sortDirection}
onSortByChange={setSortBy}
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</div>
@ -325,21 +330,22 @@ export function DocumentList() {
<DocumentUploader variant="compact" />
</div>
<div className="space-y-2">
{showHint && allDocuments.length > 1 && (
<div className="flex items-center justify-between bg-offbase border border-offbase rounded-md px-3 py-1 text-sm">
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
<Button
onClick={() => setShowHint(false)}
className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
aria-label="Dismiss hint"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
)}
{showHint && allDocuments.length > 1 && (
<div className="flex items-center justify-between bg-offbase border border-offbase rounded-md px-3 py-1 text-sm mb-2">
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
<Button
onClick={() => setShowHint(false)}
className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
aria-label="Dismiss hint"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</Button>
</div>
)}
<div className={viewMode === 'grid' ? "flex flex-wrap gap-1 w-full" : "space-y-1 w-full"}>
{folders.map(folder => (
<DocumentFolder
@ -354,6 +360,7 @@ export function DocumentList() {
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDrop={handleFolderDrop}
viewMode={viewMode}
/>
))}
@ -368,6 +375,7 @@ export function DocumentList() {
onDragLeave={handleDragLeave}
onDrop={handleDrop}
isDropTarget={dropTargetDoc?.id === doc.id}
viewMode={viewMode}
/>
))}
</div>

View file

@ -15,6 +15,7 @@ interface DocumentListItemProps {
onDragLeave?: () => void;
onDrop?: (e: DragEvent, doc: DocumentListDocument) => void;
isDropTarget?: boolean;
viewMode: 'list' | 'grid';
}
export function DocumentListItem({
@ -27,6 +28,7 @@ export function DocumentListItem({
onDragLeave,
onDrop,
isDropTarget = false,
viewMode,
}: DocumentListItemProps) {
const [loading, setLoading] = useState(false);
const router = useRouter();
@ -51,18 +53,18 @@ export function DocumentListItem({
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
aria-busy={loading}
className={`
w-full group
${viewMode === 'grid' ? 'flex-auto min-w-[200px] max-w-full' : 'w-full'} group
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
border border-offbase rounded-md p-1
transition-colors duration-150 relative
`}
>
<div className="flex items-center">
<div className="flex items-center w-full">
<Link
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
draggable={false}
className="document-link flex items-center align-center gap-2 w-full truncate rounded-md py-0.5 px-0.5"
className="document-link flex items-center align-center gap-2 flex-1 min-w-0 rounded-md py-0.5 px-0.5"
onClick={handleDocumentClick}
>
<div className="flex-shrink-0">

View file

@ -1,5 +1,5 @@
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Button } from '@headlessui/react';
import { ChevronUpDownIcon } from '@/components/icons/Icons';
import { ChevronUpDownIcon, ListIcon, GridIcon } from '@/components/icons/Icons';
import { SortBy, SortDirection } from '@/types/documents';
interface SortControlsProps {
@ -7,6 +7,8 @@ interface SortControlsProps {
sortDirection: SortDirection;
onSortByChange: (value: SortBy) => void;
onSortDirectionChange: () => void;
viewMode: 'list' | 'grid';
onViewModeChange: (mode: 'list' | 'grid') => void;
}
export function SortControls({
@ -14,6 +16,8 @@ export function SortControls({
sortDirection,
onSortByChange,
onSortDirectionChange,
viewMode,
onViewModeChange,
}: SortControlsProps) {
const sortOptions: Array<{ value: SortBy; label: string, up: string, down: string }> = [
{ value: 'name', label: 'Name', up: 'A-Z', down: 'Z-A' },
@ -25,34 +29,59 @@ export function SortControls({
const currentSort = sortOptions.find(opt => opt.value === sortBy);
const directionLabel = sortDirection === 'asc' ? currentSort?.up : currentSort?.down;
const buttonBaseClass = "h-6 flex items-center justify-center bg-base hover:bg-offbase rounded border border-transparent hover:border-offbase text-xs sm:text-sm transition-all duration-200 ease-in-out hover:scale-[1.04] hover:text-accent";
const activeIconClass = "text-accent";
const inactiveIconClass = "text-muted";
return (
<div className="flex items-center gap-1">
<Button
onClick={onSortDirectionChange}
className="px-1.5 sm:px-2 py-0.5 sm:py-1 bg-base hover:bg-offbase rounded text-xs sm:text-sm whitespace-nowrap transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
>
{directionLabel}
</Button>
<div className="relative">
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton className="flex items-center space-x-0.5 sm:space-x-1 bg-background text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent">
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
</ListboxButton>
<ListboxOptions anchor="top end" className="absolute z-50 w-28 sm:w-32 overflow-auto rounded-lg bg-background shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{sortOptions.map((option) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ active, selected }) =>
`relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
}
>
<span className="text-xs sm:text-sm">{option.label}</span>
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
<div className="hidden xs:flex items-center bg-base rounded p-[1px] gap-0.5 border border-transparent">
<Button
onClick={() => onViewModeChange('list')}
className={`p-0.5 rounded hover:bg-offbase transition-all hover:scale-[1.07] ${viewMode === 'list' ? activeIconClass : inactiveIconClass}`}
aria-label="List view"
>
<ListIcon className="w-4 h-4" />
</Button>
<Button
onClick={() => onViewModeChange('grid')}
className={`p-0.5 rounded hover:bg-offbase transition-all hover:scale-[1.07] ${viewMode === 'grid' ? activeIconClass : inactiveIconClass}`}
aria-label="Grid view"
>
<GridIcon className="w-4 h-4" />
</Button>
</div>
<div className="hidden xs:block h-4 w-px bg-offbase mx-1" />
<div className="flex items-center gap-1">
<Button
onClick={onSortDirectionChange}
className={`${buttonBaseClass} px-2 text-xs`}
>
{directionLabel}
</Button>
<div className="relative">
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton className={`${buttonBaseClass} pl-2 pr-1 gap-1 min-w-[80px] justify-between focus:outline-none focus:ring-accent focus:ring-2`}>
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
<ChevronUpDownIcon className="h-3 w-3 opacity-50" />
</ListboxButton>
<ListboxOptions anchor="top end" className="absolute z-50 w-32 overflow-auto rounded-lg bg-background shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none p-1">
{sortOptions.map((option) => (
<ListboxOption
key={option.value}
value={option.value}
className={({ active, selected }) =>
`relative cursor-pointer select-none py-1.5 px-2 rounded text-xs ${active ? 'bg-offbase text-accent' : 'text-foreground'} ${selected ? 'font-medium' : ''}`
}
>
{option.label}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
</div>
</div>
);

View file

@ -373,6 +373,50 @@ export function DotsVerticalIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
export function ChevronLeftIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className={props.className}
width={props.width || "1.25em"}
height={props.height || "1.25em"}
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M11 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
);
}
export function ChevronRightIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className={props.className}
width={props.width || "1.25em"}
height={props.height || "1.25em"}
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 5l7 7-7 7M5 5l7 7-7 7"
/>
</svg>
);
}
export function SpeedometerIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
@ -414,3 +458,55 @@ export function AudioWaveIcon(props: React.SVGProps<SVGSVGElement>) {
</svg>
);
}
export function CopyIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
);
}
export function ListIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path fillRule="evenodd" d="M2.625 6.75a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zm4.875 0A.75.75 0 018.25 6h12a.75.75 0 010 1.5h-12a.75.75 0 01-.75-.75zM2.625 12a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zM7.5 12a.75.75 0 01.75-.75h12a.75.75 0 010 1.5h-12A.75.75 0 017.5 12zm-4.875 5.25a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0zm4.875 0a.75.75 0 01.75-.75h12a.75.75 0 010 1.5h-12a.75.75 0 01-.75-.75z" clipRule="evenodd" />
</svg>
);
}
export function GridIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path fillRule="evenodd" d="M1.5 6a2.25 2.25 0 012.25-2.25h16.5A2.25 2.25 0 0122.5 6v12a2.25 2.25 0 01-2.25 2.25H3.75A2.25 2.25 0 011.5 18V6zM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0021 18v-1.94l-2.69-2.689a1.5 1.5 0 00-2.12 0l-.88.879.97.97a.75.75 0 11-1.06 1.06l-5.16-5.159a1.5 1.5 0 00-2.12 0L3 16.061zm10.125-7.81a1.125 1.125 0 112.25 0 1.125 1.125 0 01-2.25 0z" clipRule="evenodd" />
</svg>
);
}

View file

@ -9,25 +9,12 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
skipToLocation: (location: string | number, shouldPause?: boolean) => void;
}) => {
const [inputValue, setInputValue] = useState('');
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setInputValue(currentPage.toString());
}, [currentPage]);
// Auto-focus and select input when popover opens
useEffect(() => {
if (isPopoverOpen && inputRef.current) {
// Small delay to ensure the popover is fully rendered
const timer = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
return () => clearTimeout(timer);
}
}, [isPopoverOpen]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Only allow numbers
const value = e.target.value.replace(/[^0-9]/g, '');
@ -56,6 +43,11 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
const handlePopoverOpen = () => {
setInputValue(''); // Clear input when popup opens
// Auto-focus and select input shortly after opening
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 50);
};
return (
@ -73,44 +65,34 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
</Button>
{/* Page number popup */}
<Popover className="relative">
{({ open }) => {
if (open !== isPopoverOpen) {
setIsPopoverOpen(open);
}
return (
<>
<PopoverButton
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={handlePopoverOpen}
>
<p className="text-xs whitespace-nowrap">
{currentPage} / {numPages || 1}
</p>
</PopoverButton>
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase">
<div className="flex flex-col space-y-2">
<div className="text-xs font-medium text-foreground">Go to page</div>
<input
ref={inputRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
className="w-20 px-2 py-1 text-xs text-accent bg-offbase rounded border-none outline-none appearance-none text-center"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputConfirm}
onKeyDown={handleInputKeyDown}
placeholder={currentPage.toString()}
aria-label="Page number"
/>
<div className="text-xs text-muted text-center">of {numPages || 1}</div>
</div>
</PopoverPanel>
</>
);
}}
<Popover className="relative mb-1">
<PopoverButton
className="bg-offbase px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={handlePopoverOpen}
>
<p className="text-xs whitespace-nowrap">
{currentPage} / {numPages || 1}
</p>
</PopoverButton>
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase">
<div className="flex flex-col space-y-2">
<div className="text-xs font-medium text-foreground">Go to page</div>
<input
ref={inputRef}
type="text"
inputMode="numeric"
pattern="[0-9]*"
className="w-20 px-2 py-1 text-xs text-accent bg-offbase rounded border-none outline-none appearance-none text-center"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputConfirm}
onKeyDown={handleInputKeyDown}
placeholder={currentPage.toString()}
aria-label="Page number"
/>
<div className="text-xs text-muted text-center">of {numPages || 1}</div>
</div>
</PopoverPanel>
</Popover>
{/* Page forward */}
@ -126,4 +108,4 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
</Button>
</div>
);
}
}

View file

@ -32,7 +32,9 @@ interface ConfigContextType {
isLoading: boolean;
isDBReady: boolean;
pdfHighlightEnabled: boolean;
pdfWordHighlightEnabled: boolean;
epubHighlightEnabled: boolean;
epubWordHighlightEnabled: boolean;
}
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
@ -103,7 +105,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
savedVoices,
smartSentenceSplitting,
pdfHighlightEnabled,
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled,
} = config || APP_CONFIG_DEFAULTS;
/**
@ -201,7 +205,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
isLoading,
isDBReady,
pdfHighlightEnabled,
epubHighlightEnabled
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled
}}>
{children}
</ConfigContext.Provider>

View file

@ -21,8 +21,18 @@ import { useTTS } from '@/contexts/TTSContext';
import { createRangeCfi } from '@/lib/epub';
import { useParams } from 'next/navigation';
import { useConfig } from './ConfigContext';
import { withRetry } from '@/utils/audio';
import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts';
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
import { CmpStr } from 'cmpstr';
import type {
TTSSentenceAlignment,
TTSAudiobookFormat,
TTSAudiobookChapter,
} from '@/types/tts';
import type {
TTSRequestHeaders,
TTSRequestPayload,
TTSRetryOptions,
} from '@/types/client';
interface EPUBContextType {
currDocData: ArrayBuffer | undefined;
@ -33,8 +43,8 @@ interface EPUBContextType {
setCurrentDocument: (id: string) => Promise<void>;
clearCurrDoc: () => void;
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>;
regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>;
bookRef: RefObject<Book | null>;
renditionRef: RefObject<Rendition | undefined>;
tocRef: RefObject<NavItem[]>;
@ -44,12 +54,26 @@ interface EPUBContextType {
isAudioCombining: boolean;
highlightPattern: (text: string) => void;
clearHighlights: () => void;
highlightWordIndex: (
alignment: TTSSentenceAlignment | undefined,
wordIndex: number | null | undefined,
sentence: string | null | undefined
) => void;
clearWordHighlights: () => void;
}
const EPUBContext = createContext<EPUBContextType | undefined>(undefined);
const EPUB_CONTINUATION_CHARS = 600;
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
const normalizeWordForMatch = (text: string): string =>
text
.trim()
.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '')
.toLowerCase();
const stepToNextNode = (node: Node | null, root: Node): Node | null => {
if (!node) return null;
if (node.firstChild) {
@ -160,6 +184,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const shouldPauseRef = useRef(true);
// Track current highlight CFI for removal
const currentHighlightCfi = useRef<string | null>(null);
const currentWordHighlightCfi = useRef<string | null>(null);
/**
* Clears all current document state and stops any active TTS
@ -298,9 +323,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const createFullAudioBook = useCallback(async (
onProgress: (progress: number) => void,
signal?: AbortSignal,
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
providedBookId?: string,
format: 'mp3' | 'm4b' = 'mp3'
format: TTSAudiobookFormat = 'mp3'
): Promise<string> => {
try {
const sections = await extractBookText();
@ -318,18 +343,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const existingIndices = new Set<number>();
if (bookId) {
try {
const existingResponse = await fetch(`/api/audio/convert/chapters?bookId=${bookId}`);
if (existingResponse.ok) {
const existingData = await existingResponse.json();
if (existingData.chapters && existingData.chapters.length > 0) {
for (const ch of existingData.chapters) {
existingIndices.add(ch.index);
}
// Log smallest missing index for visibility
let nextMissing = 0;
while (existingIndices.has(nextMissing)) nextMissing++;
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
const existingData = await getAudiobookStatus(bookId);
if (existingData.chapters && existingData.chapters.length > 0) {
for (const ch of existingData.chapters) {
existingIndices.add(ch.index);
}
// Log smallest missing index for visibility
let nextMissing = 0;
while (existingIndices.has(nextMissing)) nextMissing++;
console.log(`Resuming; next missing chapter index is ${nextMissing}`);
}
} catch (error) {
console.error('Error checking existing chapters:', error);
@ -410,22 +432,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
throw new DOMException('Aborted', 'AbortError');
}
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal
});
if (!ttsResponse.ok) {
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
}
const buffer = await ttsResponse.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS');
}
return buffer;
return await generateTTS(reqBody, reqHeaders, signal);
},
retryOptions
);
@ -448,45 +455,21 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
}
// Send to server for conversion and storage
const convertResponse = await fetch('/api/audio/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format,
chapterIndex: i
}),
signal
});
if (convertResponse.status === 499) {
throw new Error('cancelled');
}
if (!convertResponse.ok) {
throw new Error('Failed to convert audio chapter');
}
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
const chapter = await createAudiobookChapter({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format,
chapterIndex: i
}, signal);
if (!bookId) {
bookId = returnedBookId;
bookId = chapter.bookId!;
}
// Notify about completed chapter
if (onChapterComplete) {
onChapterComplete({
index: chapterIndex,
title: chapterTitle,
duration,
status: 'completed',
bookId,
format
});
onChapterComplete(chapter);
}
processedLength += trimmedText.length;
@ -532,9 +515,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
const regenerateChapter = useCallback(async (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
format: TTSAudiobookFormat,
signal: AbortSignal
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
): Promise<TTSAudiobookChapter> => {
try {
const sections = await extractBookText();
if (chapterIndex >= sections.length) {
@ -599,22 +582,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
throw new DOMException('Aborted', 'AbortError');
}
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal
});
if (!ttsResponse.ok) {
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
}
const buffer = await ttsResponse.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS');
}
return buffer;
return await generateTTS(reqBody, reqHeaders, signal);
},
retryOptions
);
@ -624,39 +592,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
}
// Send to server for conversion and storage
const convertResponse = await fetch('/api/audio/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format,
chapterIndex
}),
signal
});
if (convertResponse.status === 499) {
throw new Error('cancelled');
}
if (!convertResponse.ok) {
throw new Error('Failed to convert audio chapter');
}
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
return {
index: returnedIndex,
title: chapterTitle,
duration,
status: 'completed',
const chapter = await createAudiobookChapter({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format
};
format,
chapterIndex
}, signal);
return chapter;
} catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
@ -711,15 +655,22 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
}
}, [id, skipToLocation, extractPageText, setIsEPUB]);
const clearHighlights = useCallback(() => {
if (renditionRef.current) {
if (currentHighlightCfi.current) {
renditionRef.current.annotations.remove(currentHighlightCfi.current, 'highlight');
currentHighlightCfi.current = null;
}
const clearWordHighlights = useCallback(() => {
if (!renditionRef.current) return;
if (currentWordHighlightCfi.current) {
renditionRef.current.annotations.remove(currentWordHighlightCfi.current, 'highlight');
currentWordHighlightCfi.current = null;
}
}, []);
const clearHighlights = useCallback(() => {
if (renditionRef.current && currentHighlightCfi.current) {
renditionRef.current.annotations.remove(currentHighlightCfi.current, 'highlight');
currentHighlightCfi.current = null;
}
clearWordHighlights();
}, [clearWordHighlights]);
const highlightPattern = useCallback(async (text: string) => {
if (!renditionRef.current) return;
@ -772,6 +723,262 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
}
}, [epubHighlightEnabled, clearHighlights]);
const highlightWordIndex = useCallback((
alignment: TTSSentenceAlignment | undefined,
wordIndex: number | null | undefined,
sentence: string | null | undefined
) => {
clearWordHighlights();
if (!epubHighlightEnabled) return;
if (!alignment) return;
if (wordIndex === null || wordIndex === undefined || wordIndex < 0) return;
const words = alignment.words || [];
if (!words.length || wordIndex >= words.length) return;
if (!renditionRef.current) return;
if (!currentHighlightCfi.current) return;
const cleanSentence =
sentence && sentence.trim()
? sentence.trim().replace(/\s+/g, ' ')
: null;
if (!cleanSentence) return;
const alignmentSentenceClean = alignment.sentence
? alignment.sentence.trim().replace(/\s+/g, ' ')
: null;
if (!alignmentSentenceClean || alignmentSentenceClean !== cleanSentence) {
return;
}
const contents = renditionRef.current.getContents();
const contentsArray = Array.isArray(contents) ? contents : [contents];
for (const content of contentsArray) {
let range: Range | null = null;
try {
range = content.range(currentHighlightCfi.current as string);
} catch {
range = null;
}
if (!range) continue;
const root = range.commonAncestorContainer;
if (!root) continue;
const domTokens: Array<{
node: Text;
startOffset: number;
endOffset: number;
norm: string;
}> = [];
const addTokensFromNode = (textNode: Text, start: number, end: number) => {
const full = textNode.textContent || '';
const safeStart = Math.max(0, Math.min(start, full.length));
const safeEnd = Math.max(safeStart, Math.min(end, full.length));
if (safeEnd <= safeStart) return;
const slice = full.slice(safeStart, safeEnd);
const wordRegex = /\S+/g;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(slice)) !== null) {
const raw = match[0];
const norm = normalizeWordForMatch(raw);
if (!norm) continue;
const tokenStart = safeStart + match.index;
const tokenEnd = tokenStart + raw.length;
domTokens.push({
node: textNode,
startOffset: tokenStart,
endOffset: tokenEnd,
norm,
});
}
};
const nextTextNode = (node: Node | null): Text | null => {
let next = getNextTextNode(node, root);
while (next) {
if (next.nodeType === Node.TEXT_NODE) {
return next as Text;
}
next = getNextTextNode(next, root);
}
return null;
};
// Collect tokens within the sentence range
if (range.startContainer === range.endContainer && range.startContainer.nodeType === Node.TEXT_NODE) {
addTokensFromNode(range.startContainer as Text, range.startOffset, range.endOffset);
} else {
let current: Text | null = null;
if (range.startContainer.nodeType === Node.TEXT_NODE) {
const startText = range.startContainer as Text;
const isEnd = range.endContainer === startText && range.endContainer.nodeType === Node.TEXT_NODE;
const endOffset = isEnd ? range.endOffset : (startText.textContent || '').length;
addTokensFromNode(startText, range.startOffset, endOffset);
if (isEnd) {
current = null;
} else {
current = nextTextNode(startText);
}
} else {
current = nextTextNode(range.startContainer);
}
while (current) {
if (range.endContainer.nodeType === Node.TEXT_NODE && current === range.endContainer) {
addTokensFromNode(current, 0, range.endOffset);
break;
} else {
addTokensFromNode(current, 0, (current.textContent || '').length);
}
current = nextTextNode(current);
}
}
if (!domTokens.length) {
return;
}
const domFiltered: Array<{ tokenIndex: number; norm: string }> = [];
for (let i = 0; i < domTokens.length; i++) {
const norm = domTokens[i].norm;
if (!norm) continue;
domFiltered.push({ tokenIndex: i, norm });
}
const ttsFiltered: Array<{ wordIndex: number; norm: string }> = [];
for (let i = 0; i < words.length; i++) {
const norm = normalizeWordForMatch(words[i].text);
if (!norm) continue;
ttsFiltered.push({ wordIndex: i, norm });
}
const wordToToken = new Array<number>(words.length).fill(-1);
const m = domFiltered.length;
const n = ttsFiltered.length;
if (m && n) {
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(Number.POSITIVE_INFINITY)
);
const bt: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0)
); // 0=diag,1=up,2=left
dp[0][0] = 0;
const GAP_COST = 0.7;
for (let i = 0; i <= m; i++) {
for (let j = 0; j <= n; j++) {
if (i > 0 && j > 0) {
const a = domFiltered[i - 1].norm;
const b = ttsFiltered[j - 1].norm;
const sim = cmp.compare(a, b);
const subCost = 1 - sim;
const cand = dp[i - 1][j - 1] + subCost;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 0;
}
}
if (i > 0) {
const cand = dp[i - 1][j] + GAP_COST;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 1;
}
}
if (j > 0) {
const cand = dp[i][j - 1] + GAP_COST;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 2;
}
}
}
}
let i = m;
let j = n;
while (i > 0 || j > 0) {
const move = bt[i][j];
if (i > 0 && j > 0 && move === 0) {
const domIdx = domFiltered[i - 1].tokenIndex;
const ttsIdx = ttsFiltered[j - 1].wordIndex;
if (wordToToken[ttsIdx] === -1) {
wordToToken[ttsIdx] = domIdx;
}
i -= 1;
j -= 1;
} else if (i > 0 && (move === 1 || j === 0)) {
i -= 1;
} else if (j > 0 && (move === 2 || i === 0)) {
j -= 1;
} else {
break;
}
}
// Propagate nearest known mapping to fill gaps
let lastSeen = -1;
for (let k = 0; k < wordToToken.length; k++) {
if (wordToToken[k] !== -1) {
lastSeen = wordToToken[k];
} else if (lastSeen !== -1) {
wordToToken[k] = lastSeen;
}
}
let nextSeen = -1;
for (let k = wordToToken.length - 1; k >= 0; k--) {
if (wordToToken[k] !== -1) {
nextSeen = wordToToken[k];
} else if (nextSeen !== -1) {
wordToToken[k] = nextSeen;
}
}
}
const mappedIndex =
wordIndex < wordToToken.length ? wordToToken[wordIndex] : -1;
if (mappedIndex === -1) {
return;
}
const token = domTokens[mappedIndex];
const doc = token.node.ownerDocument || (range.commonAncestorContainer as Document);
const wordRange = doc.createRange();
wordRange.setStart(token.node, token.startOffset);
wordRange.setEnd(token.node, token.endOffset);
try {
const wordCfi = content.cfiFromRange(wordRange);
currentWordHighlightCfi.current = wordCfi;
renditionRef.current.annotations.add(
'highlight',
wordCfi,
{},
() => {},
'',
{
fill: 'var(--accent)',
'fill-opacity': '0.4',
'mix-blend-mode': 'multiply',
}
);
} catch (error) {
console.error('Error highlighting EPUB word:', error);
}
break;
}
}, [epubHighlightEnabled, clearWordHighlights]);
// Context value memoization
@ -796,6 +1003,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
isAudioCombining,
highlightPattern,
clearHighlights,
highlightWordIndex,
clearWordHighlights,
}),
[
setCurrentDocument,
@ -813,6 +1022,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
isAudioCombining,
highlightPattern,
clearHighlights,
highlightWordIndex,
clearWordHighlights,
]
);

View file

@ -31,15 +31,26 @@ import { getPdfDocument } from '@/lib/dexie';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { processTextToSentences } from '@/lib/nlp';
import { withRetry } from '@/utils/audio';
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
import {
extractTextFromPDF,
highlightPattern,
clearHighlights,
handleTextClick,
clearWordHighlights,
highlightWordIndex,
} from '@/lib/pdf';
import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts';
import type {
TTSSentenceAlignment,
TTSAudioBuffer,
TTSAudiobookFormat,
TTSAudiobookChapter,
} from '@/types/tts';
import type {
TTSRequestHeaders,
TTSRequestPayload,
TTSRetryOptions,
} from '@/types/client';
/**
* Interface defining all available methods and properties in the PDF context
@ -59,16 +70,15 @@ interface PDFContextType {
onDocumentLoadSuccess: (pdf: PDFDocumentProxy) => void;
highlightPattern: (text: string, pattern: string, containerRef: RefObject<HTMLDivElement>) => void;
clearHighlights: () => void;
handleTextClick: (
event: MouseEvent,
pdfText: string,
containerRef: RefObject<HTMLDivElement>,
stopAndPlayFromIndex: (index: number) => void,
isProcessing: boolean,
enableHighlight?: boolean
clearWordHighlights: () => void;
highlightWordIndex: (
alignment: TTSSentenceAlignment | undefined,
wordIndex: number | null | undefined,
sentence: string | null | undefined,
containerRef: RefObject<HTMLDivElement>
) => void;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void, bookId?: string, format?: 'mp3' | 'm4b') => Promise<string>;
regenerateChapter: (chapterIndex: number, bookId: string, format: 'mp3' | 'm4b', signal: AbortSignal) => Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }>;
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => Promise<string>;
regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>;
isAudioCombining: boolean;
}
@ -252,9 +262,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const createFullAudioBook = useCallback(async (
onProgress: (progress: number) => void,
signal?: AbortSignal,
onChapterComplete?: (chapter: { index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }) => void,
onChapterComplete?: (chapter: TTSAudiobookChapter) => void,
providedBookId?: string,
format: 'mp3' | 'm4b' = 'mp3'
format: TTSAudiobookFormat = 'mp3'
): Promise<string> => {
try {
if (!pdfDocument) {
@ -294,17 +304,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const existingIndices = new Set<number>();
if (bookId) {
try {
const existingResponse = await fetch(`/api/audio/convert/chapters?bookId=${bookId}`);
if (existingResponse.ok) {
const existingData = await existingResponse.json();
if (existingData.chapters && existingData.chapters.length > 0) {
for (const ch of existingData.chapters) {
existingIndices.add(ch.index);
}
let nextMissing = 0;
while (existingIndices.has(nextMissing)) nextMissing++;
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
const existingData = await getAudiobookStatus(bookId);
if (existingData.chapters && existingData.chapters.length > 0) {
for (const ch of existingData.chapters) {
existingIndices.add(ch.index);
}
let nextMissing = 0;
while (existingIndices.has(nextMissing)) nextMissing++;
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
}
} catch (error) {
console.error('Error checking existing chapters:', error);
@ -362,22 +369,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
throw new DOMException('Aborted', 'AbortError');
}
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal
});
if (!ttsResponse.ok) {
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
}
const buffer = await ttsResponse.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS');
}
return buffer;
return await generateTTS(reqBody, reqHeaders, signal);
},
retryOptions
);
@ -394,45 +386,21 @@ export function PDFProvider({ children }: { children: ReactNode }) {
}
// Send to server for conversion and storage
const convertResponse = await fetch('/api/audio/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format,
chapterIndex: i
}),
signal
});
if (convertResponse.status === 499) {
throw new Error('cancelled');
}
if (!convertResponse.ok) {
throw new Error('Failed to convert audio chapter');
}
const { bookId: returnedBookId, chapterIndex, duration } = await convertResponse.json();
const chapter = await createAudiobookChapter({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format,
chapterIndex: i
}, signal);
if (!bookId) {
bookId = returnedBookId;
bookId = chapter.bookId!;
}
// Notify about completed chapter
if (onChapterComplete) {
onChapterComplete({
index: chapterIndex,
title: chapterTitle,
duration,
status: 'completed',
bookId,
format
});
onChapterComplete(chapter);
}
processedLength += text.length;
@ -478,9 +446,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
const regenerateChapter = useCallback(async (
chapterIndex: number,
bookId: string,
format: 'mp3' | 'm4b',
format: TTSAudiobookFormat,
signal: AbortSignal
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
): Promise<TTSAudiobookChapter> => {
try {
if (!pdfDocument) {
throw new Error('No PDF document loaded');
@ -551,28 +519,13 @@ export function PDFProvider({ children }: { children: ReactNode }) {
backoffFactor: 2
};
const audioBuffer = await withRetry(
const audioBuffer: TTSAudioBuffer = await withRetry(
async () => {
if (signal?.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal
});
if (!ttsResponse.ok) {
throw new Error(`TTS processing failed with status ${ttsResponse.status}`);
}
const buffer = await ttsResponse.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS');
}
return buffer;
return await generateTTS(reqBody, reqHeaders, signal);
},
retryOptions
);
@ -582,39 +535,15 @@ export function PDFProvider({ children }: { children: ReactNode }) {
}
// Send to server for conversion and storage
const convertResponse = await fetch('/api/audio/convert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format,
chapterIndex
}),
signal
});
if (convertResponse.status === 499) {
throw new Error('cancelled');
}
if (!convertResponse.ok) {
throw new Error('Failed to convert audio chapter');
}
const { chapterIndex: returnedIndex, duration } = await convertResponse.json();
return {
index: returnedIndex,
title: chapterTitle,
duration,
status: 'completed',
const chapter = await createAudiobookChapter({
chapterTitle,
buffer: Array.from(new Uint8Array(audioBuffer)),
bookId,
format
};
format,
chapterIndex
}, signal);
return chapter;
} catch (error) {
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
@ -655,7 +584,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
clearCurrDoc,
highlightPattern,
clearHighlights,
handleTextClick,
clearWordHighlights,
highlightWordIndex,
pdfDocument,
createFullAudioBook,
regenerateChapter,

View file

@ -36,7 +36,7 @@ import { useMediaSession } from '@/hooks/audio/useMediaSession';
import { useAudioContext } from '@/hooks/audio/useAudioContext';
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
import { withRetry } from '@/utils/audio';
import { withRetry, generateTTS, alignAudio } from '@/lib/client';
import { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp';
import { isKokoroModel } from '@/utils/voice';
import type {
@ -44,10 +44,14 @@ import type {
TTSSmartMergeResult,
TTSPageTurnEstimate,
TTSPlaybackState,
TTSSentenceAlignment,
TTSAudioBuffer,
} from '@/types/tts';
import type {
TTSRequestPayload,
TTSRequestHeaders,
TTSRetryOptions,
} from '@/types/tts';
} from '@/types/client';
// Media globals
declare global {
@ -63,6 +67,10 @@ interface TTSContextType extends TTSPlaybackState {
// Voice settings
availableVoices: string[];
// Alignment metadata for the current sentence
currentSentenceAlignment?: TTSSentenceAlignment;
currentWordIndex?: number | null;
// Control functions
togglePlay: () => void;
skipForward: () => void;
@ -238,6 +246,22 @@ const mergeContinuation = (text: string, nextText: string): TTSSmartMergeResult
};
};
const buildCacheKey = (
sentence: string,
voice: string,
speed: number,
provider: string,
model: string,
) => {
return [
`provider=${provider || ''}`,
`model=${model || ''}`,
`voice=${voice || ''}`,
`speed=${Number.isFinite(speed) ? speed : ''}`,
`text=${sentence}`,
].join('|');
};
// Create the context
const TTSContext = createContext<TTSContextType | undefined>(undefined);
@ -264,6 +288,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
updateConfigKey,
skipBlank,
smartSentenceSplitting,
pdfHighlightEnabled,
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled,
} = useConfig();
// Audio and voice management hooks
@ -331,6 +359,19 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const epubContinuationRef = useRef<string | null>(null);
const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sentenceAlignmentCacheRef = useRef<Map<string, TTSSentenceAlignment>>(new Map());
const [currentSentenceAlignment, setCurrentSentenceAlignment] = useState<TTSSentenceAlignment | undefined>();
const [currentWordIndex, setCurrentWordIndex] = useState<number | null>(null);
const sentencesRef = useRef<string[]>([]);
const currentIndexRef = useRef(0);
useEffect(() => {
sentencesRef.current = sentences;
}, [sentences]);
useEffect(() => {
currentIndexRef.current = currentIndex;
}, [currentIndex]);
/**
* Processes text into sentences using the shared NLP utility
@ -370,6 +411,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
clearTimeout(pageTurnTimeoutRef.current);
pageTurnTimeoutRef.current = null;
}
setCurrentWordIndex(null);
}, [activeHowl]);
/**
@ -558,6 +600,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setCurrentIndex(0);
}
// Reset alignment state whenever the text block changes
sentenceAlignmentCacheRef.current.clear();
setCurrentSentenceAlignment(undefined);
setCurrentWordIndex(null);
// Compute auto page-turn estimate for PDFs when we have a continuation
if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) {
const continuationNormalized = preprocessSentenceForAudio(continuationCarried);
@ -703,13 +750,71 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
* Generates and plays audio for the current sentence
*
* @param {string} sentence - The sentence to generate audio for
* @returns {Promise<ArrayBuffer | undefined>} The generated audio buffer
* @returns {Promise<TTSAudioBuffer | undefined>} The generated audio buffer
*/
const getAudio = useCallback(async (sentence: string): Promise<ArrayBuffer | undefined> => {
const getAudio = useCallback(async (sentence: string): Promise<TTSAudioBuffer | undefined> => {
const alignmentEnabledForCurrentDoc =
(!isEPUB && pdfHighlightEnabled && pdfWordHighlightEnabled) ||
(isEPUB && epubHighlightEnabled && epubWordHighlightEnabled);
// Helper to ensure we have an alignment for a given
// sentence/audio pair, even when the audio itself is
// served from the local cache.
const ensureAlignment = (arrayBuffer: TTSAudioBuffer) => {
if (!alignmentEnabledForCurrentDoc) return;
const alignmentKey = buildCacheKey(
sentence,
voice,
speed,
configTTSProvider,
ttsModel,
);
if (sentenceAlignmentCacheRef.current.has(alignmentKey)) return;
try {
const audioBytes = Array.from(new Uint8Array(arrayBuffer));
const alignmentBody = {
text: sentence,
audio: audioBytes,
};
void alignAudio(alignmentBody)
.then(async (data) => {
if (!data || !Array.isArray(data.alignments) || !data.alignments[0]) {
return;
}
const alignment = data.alignments[0] as TTSSentenceAlignment;
sentenceAlignmentCacheRef.current.set(alignmentKey, alignment);
const currentSentence = sentencesRef.current[currentIndexRef.current];
if (currentSentence === sentence) {
setCurrentSentenceAlignment(alignment);
setCurrentWordIndex(null);
}
})
.catch((err) => {
console.warn('Alignment request failed:', err);
});
} catch (err) {
console.warn('Failed to start alignment request:', err);
}
};
const audioCacheKey = buildCacheKey(
sentence,
voice,
speed,
configTTSProvider,
ttsModel,
);
// Check if the audio is already cached
const cachedAudio = audioCache.get(sentence);
const cachedAudio = audioCache.get(audioCacheKey);
if (cachedAudio) {
console.log('Using cached audio for sentence:', sentence.substring(0, 20));
// If we have audio but no alignment (e.g. after a
// navigation or TTS reset), kick off a fresh alignment
// request using the cached audio buffer.
ensureAlignment(cachedAudio);
return cachedAudio;
}
@ -746,19 +851,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const arrayBuffer = await withRetry(
async () => {
const response = await fetch('/api/tts', {
method: 'POST',
headers: reqHeaders,
body: JSON.stringify(reqBody),
signal: controller.signal,
});
if (!response.ok) {
throw new Error('Failed to generate audio');
}
return response.arrayBuffer();
return await generateTTS(reqBody, reqHeaders, controller.signal);
},
retryOptions
);
@ -767,7 +860,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
activeAbortControllers.current.delete(controller);
// Cache the array buffer
audioCache.set(sentence, arrayBuffer);
audioCache.set(audioCacheKey, arrayBuffer);
// Fire-and-forget alignment request; do not block audio playback
ensureAlignment(arrayBuffer);
return arrayBuffer;
} catch (error) {
@ -788,7 +884,21 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
});
throw error;
}
}, [voice, speed, ttsModel, ttsInstructions, audioCache, openApiKey, openApiBaseUrl, configTTSProvider]);
}, [
voice,
speed,
ttsModel,
ttsInstructions,
audioCache,
openApiKey,
openApiBaseUrl,
configTTSProvider,
isEPUB,
pdfHighlightEnabled,
pdfWordHighlightEnabled,
epubHighlightEnabled,
epubWordHighlightEnabled
]);
/**
* Processes and plays the current sentence
@ -1004,11 +1114,28 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
const playAudio = useCallback(async () => {
const howl = await playSentenceWithHowl(sentences[currentIndex], currentIndex);
const sentence = sentences[currentIndex];
const alignmentKey = buildCacheKey(
sentence,
voice,
speed,
configTTSProvider,
ttsModel,
);
const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey);
if (cachedAlignment) {
setCurrentSentenceAlignment(cachedAlignment);
setCurrentWordIndex(null);
} else {
setCurrentSentenceAlignment(undefined);
setCurrentWordIndex(null);
}
const howl = await playSentenceWithHowl(sentence, currentIndex);
if (howl) {
howl.play();
}
}, [sentences, currentIndex, playSentenceWithHowl]);
}, [sentences, currentIndex, playSentenceWithHowl, voice, speed, configTTSProvider, ttsModel]);
// Place useBackgroundState after playAudio is defined
const isBackgrounded = useBackgroundState({
@ -1017,22 +1144,73 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
playAudio,
});
// Track the current word index during playback using Howler's seek position
useEffect(() => {
if (!activeHowl || !isPlaying || !currentSentenceAlignment || !currentSentenceAlignment.words.length) {
setCurrentWordIndex(null);
return;
}
let frameId: number;
const tick = () => {
try {
const pos = activeHowl.seek() as number;
if (typeof pos === 'number' && Number.isFinite(pos)) {
const words = currentSentenceAlignment.words;
let idx = -1;
for (let i = 0; i < words.length; i++) {
const w = words[i];
if (pos >= w.startSec && pos < w.endSec) {
idx = i;
break;
}
}
if (idx !== -1) {
setCurrentWordIndex((prev) => (prev === idx ? prev : idx));
}
}
} catch {
// ignore seek errors
}
frameId = requestAnimationFrame(tick);
};
frameId = requestAnimationFrame(tick);
return () => {
if (frameId) {
cancelAnimationFrame(frameId);
}
};
}, [activeHowl, isPlaying, currentSentenceAlignment]);
/**
* Preloads the next sentence's audio
*/
const preloadNextAudio = useCallback(async () => {
try {
const nextSentence = sentences[currentIndex + 1];
if (nextSentence && !audioCache.has(nextSentence) && !preloadRequests.current.has(nextSentence)) {
if (nextSentence) {
const nextKey = buildCacheKey(
nextSentence,
voice,
speed,
configTTSProvider,
ttsModel,
);
if (!audioCache.has(nextKey) && !preloadRequests.current.has(nextSentence)) {
// Start preloading but don't wait for it to complete
processSentence(nextSentence, true).catch(error => {
console.error('Error preloading next sentence:', error);
});
processSentence(nextSentence, true).catch(error => {
console.error('Error preloading next sentence:', error);
});
}
}
} catch (error) {
console.error('Error initiating preload:', error);
}
}, [currentIndex, sentences, audioCache, processSentence]);
}, [currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]);
/**
* Main Playback Driver
@ -1085,6 +1263,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setCurrDocPages(undefined);
setIsProcessing(false);
setIsEPUB(false);
sentenceAlignmentCacheRef.current.clear();
setCurrentSentenceAlignment(undefined);
setCurrentWordIndex(null);
}, [abortAudio]);
/**
@ -1118,9 +1299,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
abortAudio(true); // Clear pending requests since speed changed
setActiveHowl(null);
// Update speed, clear cache, and config
// Update speed and config
setSpeed(newSpeed);
audioCache.clear();
// Update config after state changes
updateConfigKey('voiceSpeed', newSpeed).then(() => {
@ -1130,7 +1310,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setIsPlaying(true);
}
});
}, [abortAudio, updateConfigKey, audioCache, isPlaying]);
}, [abortAudio, updateConfigKey, isPlaying]);
/**
* Sets the voice and restarts the playback
@ -1151,9 +1331,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
abortAudio(true); // Clear pending requests since voice changed
setActiveHowl(null);
// Update voice, clear cache, and config
// Update voice and config
setVoice(newVoice);
audioCache.clear();
// Update config after state changes
updateConfigKey('voice', newVoice).then(() => {
@ -1163,7 +1342,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setIsPlaying(true);
}
});
}, [abortAudio, updateConfigKey, audioCache, isPlaying]);
}, [abortAudio, updateConfigKey, isPlaying]);
/**
* Sets the audio player speed and restarts the playback
@ -1205,6 +1384,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
isProcessing,
isBackgrounded,
currentSentence: sentences[currentIndex] || '',
currentSentenceAlignment,
currentWordIndex,
currDocPage,
currDocPageNumber,
currDocPages,
@ -1248,7 +1429,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
skipToLocation,
registerLocationChangeHandler,
registerVisualPageChangeHandler,
setIsEPUB
setIsEPUB,
currentSentenceAlignment,
currentWordIndex
]);
// Use media session hook

View file

@ -2,6 +2,7 @@
import { useRef } from 'react';
import { LRUCache } from 'lru-cache';
import type { TTSAudioBuffer } from '@/types/tts';
/**
* Custom hook for managing audio cache using LRU strategy
@ -9,11 +10,11 @@ import { LRUCache } from 'lru-cache';
* @returns Object containing cache methods
*/
export function useAudioCache(maxSize = 50) {
const cacheRef = useRef(new LRUCache<string, ArrayBuffer>({ max: maxSize }));
const cacheRef = useRef(new LRUCache<string, TTSAudioBuffer>({ max: maxSize }));
return {
get: (key: string) => cacheRef.current.get(key),
set: (key: string, value: ArrayBuffer) => cacheRef.current.set(key, value),
set: (key: string, value: TTSAudioBuffer) => cacheRef.current.set(key, value),
delete: (key: string) => cacheRef.current.delete(key),
has: (key: string) => cacheRef.current.has(key),
clear: () => cacheRef.current.clear(),

View file

@ -1,6 +1,7 @@
'use client';
import { useState, useCallback } from 'react';
import { getVoices } from '@/lib/client';
const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
@ -23,18 +24,14 @@ export function useVoiceManagement(
const fetchVoices = useCallback(async () => {
try {
console.log('Fetching voices...');
const response = await fetch('/api/tts/voices', {
headers: {
'x-openai-key': apiKey || '',
'x-openai-base-url': baseUrl || '',
'x-tts-provider': ttsProvider || 'openai',
'x-tts-model': ttsModel || 'tts-1',
'Content-Type': 'application/json',
},
const data = await getVoices({
'x-openai-key': apiKey || '',
'x-openai-base-url': baseUrl || '',
'x-tts-provider': ttsProvider || 'openai',
'x-tts-model': ttsModel || 'tts-1',
'Content-Type': 'application/json',
});
if (!response.ok) throw new Error('Failed to fetch voices');
const data = await response.json();
setAvailableVoices(data.voices || DEFAULT_VOICES);
} catch (error) {
console.error('Error fetching voices:', error);

View file

@ -2,14 +2,41 @@ import { useCallback, useEffect } from 'react';
import { Rendition } from 'epubjs';
import { ReactReaderStyle, IReactReaderStyle } from 'react-reader';
export const getThemeStyles = (): IReactReaderStyle => {
const baseStyle = {
...ReactReaderStyle,
readerArea: {
...ReactReaderStyle.readerArea,
transition: undefined,
}
};
// Returns ReactReader styles, with:
// - default look when epubTheme === false (except hiding built-in arrows)
// - themed colors + layout tweaks when epubTheme === true
export const getThemeStyles = (epubTheme: boolean): IReactReaderStyle => {
const baseStyle = ReactReaderStyle;
// Always hide the built-in prev/next arrow buttons so we can
// provide our own navigation controls outside the reader.
if (!epubTheme) {
return {
...baseStyle,
reader: {
...baseStyle.reader,
// Always tighten the inset a bit for better use of space
top: 8,
left: 8,
right: 8,
bottom: 8,
},
prev: {
...baseStyle.prev,
display: 'none',
pointerEvents: 'none',
},
next: {
...baseStyle.next,
display: 'none',
pointerEvents: 'none',
},
titleArea: {
...baseStyle.titleArea,
display: 'none',
},
};
}
const colors = {
background: getComputedStyle(document.documentElement).getPropertyValue('--background'),
@ -21,6 +48,25 @@ export const getThemeStyles = (): IReactReaderStyle => {
return {
...baseStyle,
reader: {
...baseStyle.reader,
// Reduce the large default inset (50px 50px 20px)
// so the EPUB content can use more of the available area.
top: 8,
left: 8,
right: 8,
bottom: 8,
},
prev: {
...baseStyle.prev,
display: 'none',
pointerEvents: 'none',
},
next: {
...baseStyle.next,
display: 'none',
pointerEvents: 'none',
},
arrow: {
...baseStyle.arrow,
color: colors.foreground,
@ -54,6 +100,9 @@ export const getThemeStyles = (): IReactReaderStyle => {
tocButton: {
...baseStyle.tocButton,
color: colors.muted,
// Ensure the TOC toggle sits above the swipe wrapper
// and text iframe, avoiding z-index conflicts.
zIndex: 300,
},
tocAreaButton: {
...baseStyle.tocAreaButton,
@ -117,4 +166,4 @@ export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefine
}, [epubTheme, rendition, updateTheme]);
return { updateTheme };
};
};

205
src/lib/client.ts Normal file
View file

@ -0,0 +1,205 @@
import type {
TTSRequestPayload,
TTSRequestHeaders,
TTSRetryOptions,
AudiobookStatusResponse,
CreateChapterPayload,
VoicesResponse,
AlignmentPayload,
AlignmentResponse
} from '@/types/client';
import type { TTSAudiobookChapter, TTSAudioBuffer } from '@/types/tts';
/**
* Executes a function with exponential backoff retry logic
* @param operation Function to retry
* @param options Retry configuration options
* @returns Promise resolving to the operation result
*/
export const withRetry = async <T>(
operation: () => Promise<T>,
options: TTSRetryOptions = {}
): Promise<T> => {
const {
maxRetries = 3,
initialDelay = 1000,
maxDelay = 10000,
backoffFactor = 2
} = options;
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Do not retry on explicit cancellation/abort errors - surface them
// immediately so callers can stop work quickly when the user cancels.
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) {
break;
}
if (attempt === maxRetries - 1) {
break;
}
const delay = Math.min(
initialDelay * Math.pow(backoffFactor, attempt),
maxDelay
);
console.log(`Retry attempt ${attempt + 1}/${maxRetries} failed. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError || new Error('Operation failed after retries');
};
// --- Documents API ---
export const convertDocxToPdf = async (file: File): Promise<Blob> => {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/documents/docx-to-pdf', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Failed to convert DOCX to PDF');
}
return await response.blob();
};
export const deleteServerDocuments = async (): Promise<void> => {
const response = await fetch('/api/documents', {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Failed to delete server documents');
}
};
// --- Audiobook API ---
export const getAudiobookStatus = async (bookId: string): Promise<AudiobookStatusResponse> => {
const response = await fetch(`/api/audiobook/status?bookId=${bookId}`);
if (!response.ok) {
throw new Error('Failed to fetch audiobook status');
}
return await response.json();
};
export const createAudiobookChapter = async (
payload: CreateChapterPayload,
signal?: AbortSignal
): Promise<TTSAudiobookChapter> => {
const response = await fetch(`/api/audiobook`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
signal
});
if (response.status === 499) {
throw new Error('cancelled');
}
if (!response.ok) {
throw new Error('Failed to convert audio chapter');
}
return await response.json();
};
export const deleteAudiobook = async (bookId: string): Promise<void> => {
const response = await fetch(`/api/audiobook?bookId=${bookId}`, { method: 'DELETE' });
if (!response.ok) {
throw new Error('Reset failed');
}
};
export const downloadAudiobook = async (bookId: string, format: string): Promise<Response> => {
const response = await fetch(`/api/audiobook?bookId=${bookId}&format=${format}`);
if (!response.ok) throw new Error('Download failed');
return response;
};
export const deleteAudiobookChapter = async (bookId: string, chapterIndex: number): Promise<void> => {
const response = await fetch(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=${chapterIndex}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Delete failed');
}
};
export const downloadAudiobookChapter = async (bookId: string, chapterIndex: number): Promise<Blob> => {
const response = await fetch(`/api/audiobook/chapter?bookId=${bookId}&chapterIndex=${chapterIndex}`);
if (!response.ok) throw new Error('Download failed');
return await response.blob();
};
// --- TTS API ---
export const getVoices = async (headers: HeadersInit): Promise<VoicesResponse> => {
const response = await fetch('/api/tts/voices', {
headers,
});
if (!response.ok) throw new Error('Failed to fetch voices');
return await response.json();
};
export const generateTTS = async (
payload: TTSRequestPayload,
headers: TTSRequestHeaders,
signal?: AbortSignal
): Promise<TTSAudioBuffer> => {
const response = await fetch('/api/tts', {
method: 'POST',
headers: headers as HeadersInit,
body: JSON.stringify(payload),
signal
});
if (!response.ok) {
throw new Error(`TTS processing failed with status ${response.status}`);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength === 0) {
throw new Error('Received empty audio buffer from TTS');
}
return buffer;
};
// --- Whisper API ---
export const alignAudio = async (payload: AlignmentPayload): Promise<AlignmentResponse | null> => {
const response = await fetch('/api/whisper', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (!response.ok) return null;
return await response.json();
};

View file

@ -136,6 +136,12 @@ function buildAppConfigFromRaw(raw: RawConfigMap): AppConfigRow {
savedVoices,
pdfHighlightEnabled:
raw.pdfHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfHighlightEnabled,
pdfWordHighlightEnabled:
raw.pdfWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.pdfWordHighlightEnabled,
epubHighlightEnabled:
raw.epubHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubHighlightEnabled,
epubWordHighlightEnabled:
raw.epubWordHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.epubWordHighlightEnabled,
firstVisit: raw.firstVisit === 'true',
documentListState,
};

View file

@ -2,10 +2,10 @@ import { pdfjs } from 'react-pdf';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist';
import "core-js/proposals/promise-with-resolvers";
import { processTextToSentences } from '@/lib/nlp';
import type { TTSSentenceAlignment } from '@/types/tts';
import { CmpStr } from 'cmpstr';
const cmp = CmpStr.create().setMetric( 'dice' ).setFlags( 'itw' );
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
// Worker coordination for offloading highlight token matching
interface HighlightTokenMatchRequest {
@ -141,12 +141,25 @@ try {
console.error('Error patching TextLayer.render:', e);
}
interface TextMatch {
elements: HTMLElement[];
rating: number;
type PDFToken = {
spanIndex: number;
textNode: Text;
text: string;
lengthDiff: number;
}
startOffset: number;
endOffset: number;
};
let lastSpanNodes: HTMLElement[] = [];
let lastTokens: PDFToken[] = [];
let lastSentenceTokenWindow: { start: number; end: number } | null = null;
let lastSentencePattern: string | null = null;
let lastSentenceWordToTokenMap: number[] | null = null;
const normalizeWordForMatch = (text: string): string =>
text
.trim()
.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '')
.toLowerCase();
// Text Processing functions
export async function extractTextFromPDF(
@ -279,50 +292,23 @@ export function clearHighlights() {
element.parentElement.removeChild(element);
}
});
const wordOverlays = document.querySelectorAll('.pdf-word-highlight-overlay');
wordOverlays.forEach((node) => {
const element = node as HTMLElement;
if (element.parentElement) {
element.parentElement.removeChild(element);
}
});
}
export function findBestTextMatch(
elements: Array<{ element: HTMLElement; text: string }>,
targetText: string,
maxCombinedLength: number
): TextMatch {
let bestMatch = {
elements: [] as HTMLElement[],
rating: 0,
text: '',
lengthDiff: Infinity,
};
const SPAN_SEARCH_LIMIT = 10;
for (let i = 0; i < elements.length; i++) {
let combinedText = '';
const currentElements = [];
for (let j = i; j < Math.min(i + SPAN_SEARCH_LIMIT, elements.length); j++) {
const node = elements[j];
const newText = combinedText ? `${combinedText} ${node.text}` : node.text;
if (newText.length > maxCombinedLength) break;
combinedText = newText;
currentElements.push(node.element);
const similarity = cmp.compare(combinedText, targetText);
const lengthDiff = Math.abs(combinedText.length - targetText.length);
const lengthPenalty = lengthDiff / targetText.length;
const adjustedRating = similarity * (1 - lengthPenalty * 0.5);
if (adjustedRating > bestMatch.rating) {
bestMatch = {
elements: [...currentElements],
rating: adjustedRating,
text: combinedText,
lengthDiff,
};
}
export function clearWordHighlights() {
const wordOverlays = document.querySelectorAll('.pdf-word-highlight-overlay');
wordOverlays.forEach((node) => {
const element = node as HTMLElement;
if (element.parentElement) {
element.parentElement.removeChild(element);
}
}
return bestMatch;
});
}
export function highlightPattern(
@ -338,22 +324,18 @@ export function highlightPattern(
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
if (!cleanPattern) return;
lastSentencePattern = cleanPattern;
lastSentenceWordToTokenMap = null;
lastSentenceTokenWindow = null;
const spanNodes = Array.from(
container.querySelectorAll('.react-pdf__Page__textContent span')
) as HTMLElement[];
if (!spanNodes.length) return;
lastSpanNodes = spanNodes;
type Token = {
spanIndex: number;
textNode: Text;
text: string;
startOffset: number;
endOffset: number;
};
const tokens: Token[] = [];
const tokens: PDFToken[] = [];
spanNodes.forEach((span, spanIndex) => {
const node = span.firstChild;
@ -377,6 +359,7 @@ export function highlightPattern(
});
if (!tokens.length) return;
lastTokens = tokens;
const patternLen = cleanPattern.length;
@ -415,6 +398,11 @@ export function highlightPattern(
bestLengthDiff < patternLen * 0.3 ? 0.3 : 0.5;
if (hasTokenMatch && bestRating >= similarityThreshold) {
lastSentenceTokenWindow = {
start: bestStart,
end: bestEnd,
};
const rangesBySpan = new Map<
number,
{ startOffset: number; endOffset: number }
@ -454,65 +442,6 @@ export function highlightPattern(
});
}
// Fallback: if token-level matching failed, use span-based fuzzy matching
if (!highlightRanges.length) {
const spanEntries = spanNodes
.map((node) => ({
element: node as HTMLElement,
text: (node.textContent || '').trim(),
}))
.filter((entry) => entry.text.length > 0);
const containerRect = container.getBoundingClientRect();
const visibleTop = container.scrollTop;
const visibleBottom = visibleTop + containerRect.height;
const bufferSize = containerRect.height;
const visibleNodes = spanEntries.filter(({ element }) => {
const rect = element.getBoundingClientRect();
const elementTop =
rect.top - containerRect.top + container.scrollTop;
return (
elementTop >= visibleTop - bufferSize &&
elementTop <= visibleBottom + bufferSize
);
});
let bestMatch = findBestTextMatch(
visibleNodes,
cleanPattern,
cleanPattern.length * 2
);
if (bestMatch.rating < 0.3) {
bestMatch = findBestTextMatch(
spanEntries,
cleanPattern,
cleanPattern.length * 2
);
}
const spanSimilarityThreshold =
bestMatch.lengthDiff < cleanPattern.length * 0.3 ? 0.3 : 0.5;
if (bestMatch.rating >= spanSimilarityThreshold) {
bestMatch.elements.forEach((element) => {
const node = element.firstChild;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
const textNode = node as Text;
const content = textNode.textContent || '';
if (!content) return;
highlightRanges.push({
textNode,
startOffset: 0,
endOffset: content.length,
span: element,
});
});
}
}
if (!highlightRanges.length) return;
// Create overlay rectangles for each range, relative to its page text layer
@ -577,7 +506,7 @@ export function highlightPattern(
runHighlightTokenMatch(cleanPattern, tokenTexts)
.then((result) => {
if (!result || result.bestStart === -1) {
// No worker result or no good match; rely on span-level fallback
// No worker result or no good match; nothing to highlight
applyHighlightFromTokens(null);
} else {
applyHighlightFromTokens({
@ -590,95 +519,199 @@ export function highlightPattern(
})
.catch((error) => {
console.error(
'Error in PDF highlight worker, falling back to span-based matching:',
'Error in PDF highlight worker; no highlights applied:',
error
);
applyHighlightFromTokens(null);
});
}
// Text Click Handler
export function handleTextClick(
event: MouseEvent,
pdfText: string,
containerRef: React.RefObject<HTMLDivElement>,
stopAndPlayFromIndex: (index: number) => void,
isProcessing: boolean,
enableHighlight = true
export function highlightWordIndex(
alignment: TTSSentenceAlignment | undefined,
wordIndex: number | null | undefined,
sentence: string | null | undefined,
containerRef: React.RefObject<HTMLDivElement>
) {
if (isProcessing) return;
clearWordHighlights();
const target = event.target as HTMLElement;
if (!target.matches('.react-pdf__Page__textContent span')) return;
const parentElement = target.closest('.react-pdf__Page__textContent');
if (!parentElement) return;
const spans = Array.from(parentElement.querySelectorAll('span'));
const clickedIndex = spans.indexOf(target);
const contextWindow = 3;
const startIndex = Math.max(0, clickedIndex - contextWindow);
const endIndex = Math.min(spans.length - 1, clickedIndex + contextWindow);
const contextText = spans
.slice(startIndex, endIndex + 1)
.map((span) => span.textContent)
.join(' ')
.trim();
if (!contextText?.trim()) return;
const cleanContext = contextText.trim().replace(/\s+/g, ' ');
// Fast path when highlight overlays are disabled:
// avoid expensive span-level fuzzy matching and just map
// the clicked context to a sentence using cheap string checks.
if (!enableHighlight) {
const sentences = processTextToSentences(pdfText);
const idx = sentences.findIndex((sentence) => {
const cleanSentence = sentence.trim().replace(/\s+/g, ' ');
return (
cleanSentence.includes(cleanContext) ||
cleanContext.includes(cleanSentence)
);
});
if (idx !== -1) {
stopAndPlayFromIndex(idx);
}
if (!alignment) return;
if (wordIndex === null || wordIndex === undefined || wordIndex < 0) {
return;
}
const allText = Array.from(parentElement.querySelectorAll('span')).map((node) => ({
element: node as HTMLElement,
text: (node.textContent || '').trim(),
})).filter((node) => node.text.length > 0);
const words = alignment.words || [];
if (!words.length || wordIndex >= words.length) return;
const bestMatch = findBestTextMatch(allText, cleanContext, cleanContext.length * 2);
const similarityThreshold = bestMatch.lengthDiff < cleanContext.length * 0.3 ? 0.3 : 0.5;
const container = containerRef.current;
if (!container) return;
if (!lastSentenceTokenWindow) return;
if (!lastTokens.length || !lastSpanNodes.length) return;
if (bestMatch.rating >= similarityThreshold) {
const matchText = bestMatch.text;
// Use the same sentence processing logic as TTSContext for consistency
const sentences = processTextToSentences(pdfText);
console.log("sentences inside handleTextClick: %d", sentences.length)
let bestSentenceMatch = { sentence: '', rating: 0 };
const cleanSentence =
sentence && sentence.trim()
? sentence.trim().replace(/\s+/g, ' ')
: null;
if (!cleanSentence || !lastSentencePattern) return;
if (cleanSentence !== lastSentencePattern) return;
for (const sentence of sentences) {
const rating = cmp.compare(matchText, sentence);
if (rating > bestSentenceMatch.rating) {
bestSentenceMatch = { sentence, rating };
}
const start = lastSentenceTokenWindow.start;
const end = lastSentenceTokenWindow.end;
if (end < start) return;
// Lazily build or refresh the mapping from alignment word
// indices to PDF token indices for this sentence window.
if (
!lastSentenceWordToTokenMap ||
lastSentenceWordToTokenMap.length !== words.length
) {
const pdfFiltered: { tokenIndex: number; norm: string }[] = [];
for (let i = start; i <= end; i++) {
const norm = normalizeWordForMatch(lastTokens[i].text);
if (!norm) continue;
pdfFiltered.push({ tokenIndex: i, norm });
}
if (bestSentenceMatch.rating >= 0.5) {
const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence);
if (sentenceIndex !== -1) {
stopAndPlayFromIndex(sentenceIndex);
if (enableHighlight) {
highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef);
const ttsFiltered: { wordIndex: number; norm: string }[] = [];
for (let i = 0; i < words.length; i++) {
const norm = normalizeWordForMatch(words[i].text);
if (!norm) continue;
ttsFiltered.push({ wordIndex: i, norm });
}
const wordToToken = new Array<number>(words.length).fill(-1);
const m = pdfFiltered.length;
const n = ttsFiltered.length;
if (m && n) {
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(Number.POSITIVE_INFINITY)
);
const bt: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0)
); // 0=diag,1=up,2=left
dp[0][0] = 0;
const GAP_COST = 0.7;
for (let i = 0; i <= m; i++) {
for (let j = 0; j <= n; j++) {
if (i > 0 && j > 0) {
const a = pdfFiltered[i - 1].norm;
const b = ttsFiltered[j - 1].norm;
const sim = cmp.compare(a, b);
const subCost = 1 - sim;
const cand = dp[i - 1][j - 1] + subCost;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 0;
}
}
if (i > 0) {
const cand = dp[i - 1][j] + GAP_COST;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 1;
}
}
if (j > 0) {
const cand = dp[i][j - 1] + GAP_COST;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 2;
}
}
}
}
let i = m;
let j = n;
while (i > 0 || j > 0) {
const move = bt[i][j];
if (i > 0 && j > 0 && move === 0) {
const pdfIdx = pdfFiltered[i - 1].tokenIndex;
const ttsIdx = ttsFiltered[j - 1].wordIndex;
if (wordToToken[ttsIdx] === -1) {
wordToToken[ttsIdx] = pdfIdx;
}
i -= 1;
j -= 1;
} else if (i > 0 && (move === 1 || j === 0)) {
i -= 1;
} else if (j > 0 && (move === 2 || i === 0)) {
j -= 1;
} else {
break;
}
}
// Propagate nearest known mapping to fill gaps
let lastSeen = -1;
for (let k = 0; k < wordToToken.length; k++) {
if (wordToToken[k] !== -1) {
lastSeen = wordToToken[k];
} else if (lastSeen !== -1) {
wordToToken[k] = lastSeen;
}
}
let nextSeen = -1;
for (let k = wordToToken.length - 1; k >= 0; k--) {
if (wordToToken[k] !== -1) {
nextSeen = wordToToken[k];
} else if (nextSeen !== -1) {
wordToToken[k] = nextSeen;
}
}
}
lastSentenceWordToTokenMap = wordToToken;
}
const mappedIndex =
lastSentenceWordToTokenMap && wordIndex < lastSentenceWordToTokenMap.length
? lastSentenceWordToTokenMap[wordIndex]
: -1;
if (mappedIndex === -1) return;
const chosenTokenIndex = mappedIndex;
const token = lastTokens[chosenTokenIndex];
const span = lastSpanNodes[token.spanIndex];
if (!span) return;
const node = token.textNode;
if (!node || node.nodeType !== Node.TEXT_NODE) return;
try {
const range = document.createRange();
range.setStart(node, token.startOffset);
range.setEnd(node, token.endOffset);
const pageLayer = span.closest(
'.react-pdf__Page__textContent'
) as HTMLElement | null;
if (!pageLayer) return;
const pageRect = pageLayer.getBoundingClientRect();
const rects = Array.from(range.getClientRects());
rects.forEach((rect) => {
const highlight = document.createElement('div');
highlight.className = 'pdf-word-highlight-overlay';
highlight.style.position = 'absolute';
highlight.style.backgroundColor = 'var(--accent)';
highlight.style.opacity = '0.4';
highlight.style.pointerEvents = 'none';
highlight.style.left = `${rect.left - pageRect.left}px`;
highlight.style.top = `${rect.top - pageRect.top}px`;
highlight.style.width = `${rect.width}px`;
highlight.style.height = `${rect.height}px`;
highlight.style.zIndex = '2';
pageLayer.appendChild(highlight);
});
} catch {
// Ignore range errors
}
}

67
src/types/client.ts Normal file
View file

@ -0,0 +1,67 @@
import type {
TTSAudiobookChapter,
TTSSentenceAlignment,
TTSAudioBytes,
TTSAudiobookFormat,
} from '@/types/tts';
// --- TTS Client Request Types ---
// Supported output formats for the TTS endpoint
export type TTSRequestFormat = 'mp3';
// JSON payload accepted by the /api/tts endpoint
export interface TTSRequestPayload {
text: string;
voice: string;
speed: number;
model?: string | null;
format?: TTSRequestFormat;
instructions?: string;
}
// Headers used when calling the /api/tts endpoint from the client
export type TTSRequestHeaders = Record<string, string>;
// Options for retrying TTS requests on failure in withRetry
export interface TTSRetryOptions {
maxRetries?: number;
initialDelay?: number;
maxDelay?: number;
backoffFactor?: number;
}
// --- Audiobook API Types ---
export interface AudiobookStatusResponse {
exists: boolean;
chapters: TTSAudiobookChapter[];
bookId: string | null;
hasComplete: boolean;
}
export interface CreateChapterPayload {
chapterTitle: string;
buffer: TTSAudioBytes; // Array.from(new Uint8Array(audioBuffer))
bookId: string;
format: TTSAudiobookFormat;
chapterIndex: number;
}
// --- TTS Voices API Types ---
export interface VoicesResponse {
voices: string[];
}
// --- Whisper API Types ---
export interface AlignmentPayload {
text: string;
audio: TTSAudioBytes; // Array.from(new Uint8Array(arrayBuffer))
}
export interface AlignmentResponse {
alignments: TTSSentenceAlignment[];
}

View file

@ -1,5 +1,7 @@
import type { DocumentListState } from '@/types/documents';
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
export type ViewType = 'single' | 'dual' | 'scroll';
export type SavedVoices = Record<string, string>;
@ -23,7 +25,9 @@ export interface AppConfigValues {
savedVoices: SavedVoices;
smartSentenceSplitting: boolean;
pdfHighlightEnabled: boolean;
pdfWordHighlightEnabled: boolean;
epubHighlightEnabled: boolean;
epubWordHighlightEnabled: boolean;
firstVisit: boolean;
documentListState: DocumentListState;
}
@ -41,13 +45,15 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
footerMargin: 0,
leftMargin: 0,
rightMargin: 0,
ttsProvider: 'custom-openai',
ttsModel: 'kokoro',
ttsProvider: isDev ? 'custom-openai' : 'deepinfra',
ttsModel: isDev ? 'kokoro' : 'hexgrad/Kokoro-82M',
ttsInstructions: '',
savedVoices: {},
smartSentenceSplitting: true,
pdfHighlightEnabled: true,
pdfWordHighlightEnabled: isDev,
epubHighlightEnabled: true,
epubWordHighlightEnabled: isDev,
firstVisit: false,
documentListState: {
sortBy: 'name',
@ -55,6 +61,7 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
folders: [],
collapsedFolders: [],
showHint: true,
viewMode: 'grid',
},
};

View file

@ -63,4 +63,5 @@ export interface DocumentListState {
folders: Folder[];
collapsedFolders: string[];
showHint: boolean;
viewMode?: 'list' | 'grid';
}

View file

@ -1,5 +1,9 @@
export type TTSLocation = string | number;
// Core audio representations used across TTS and audiobook flows
export type TTSAudioBuffer = ArrayBuffer;
export type TTSAudioBytes = number[]; // JSON-safe representation (Array.from(new Uint8Array(buffer)))
// Standardized error codes for the TTS API
export type TTSErrorCode =
| 'MISSING_PARAMETERS'
@ -15,22 +19,6 @@ export interface TTSError {
details?: unknown;
}
// Supported output formats for the TTS endpoint
export type TTSRequestFormat = 'mp3' | 'aac';
// JSON payload accepted by the /api/tts endpoint
export interface TTSRequestPayload {
text: string;
voice: string;
speed: number;
model?: string | null;
format?: TTSRequestFormat;
instructions?: string;
}
// Headers used when calling the /api/tts endpoint from the client
export type TTSRequestHeaders = Record<string, string>;
// Core playback state exposed by the TTS context
export interface TTSPlaybackState {
isPlaying: boolean;
@ -42,14 +30,6 @@ export interface TTSPlaybackState {
currDocPages?: number;
}
// Options for retrying TTS requests on failure in withRetry
export interface TTSRetryOptions {
maxRetries?: number;
initialDelay?: number;
maxDelay?: number;
backoffFactor?: number;
}
// Result of merging a continuation slice into the current text
export interface TTSSmartMergeResult {
text: string;
@ -63,6 +43,25 @@ export interface TTSPageTurnEstimate {
fraction: number;
}
// Word-level alignment within a single spoken sentence/block
export interface TTSSentenceWord {
text: string;
startSec: number;
endSec: number;
charStart: number;
charEnd: number;
}
// Alignment metadata for a single TTS sentence/block
export interface TTSSentenceAlignment {
sentence: string;
sentenceIndex: number;
words: TTSSentenceWord[];
}
// Supported output formats for generated audiobooks
export type TTSAudiobookFormat = 'mp3' | 'm4b';
// Metadata for an audiobook chapter
export interface TTSAudiobookChapter {
index: number;
@ -70,5 +69,5 @@ export interface TTSAudiobookChapter {
duration?: number;
status: 'pending' | 'generating' | 'completed' | 'error';
bookId?: string;
format?: 'mp3' | 'm4b';
}
format?: TTSAudiobookFormat;
}

View file

@ -1,49 +0,0 @@
import type { TTSRetryOptions } from '@/types/tts';
/**
* Executes a function with exponential backoff retry logic
* @param operation Function to retry
* @param options Retry configuration options
* @returns Promise resolving to the operation result
*/
export const withRetry = async <T>(
operation: () => Promise<T>,
options: TTSRetryOptions = {}
): Promise<T> => {
const {
maxRetries = 3,
initialDelay = 1000,
maxDelay = 10000,
backoffFactor = 2
} = options;
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Do not retry on explicit cancellation/abort errors - surface them
// immediately so callers can stop work quickly when the user cancels.
if (lastError.name === 'AbortError' || lastError.message.includes('cancelled')) {
break;
}
if (attempt === maxRetries - 1) {
break;
}
const delay = Math.min(
initialDelay * Math.pow(backoffFactor, attempt),
maxDelay
);
console.log(`Retry attempt ${attempt + 1}/${maxRetries} failed. Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw lastError || new Error('Operation failed after retries');
};

View file

@ -67,6 +67,9 @@ export default {
},
},
},
screens: {
xs: '410px', // custom xs breakpoint
},
},
plugins: [typography],
} satisfies Config;

View file

@ -5,4 +5,7 @@ API_KEY=api_key_here_if_needed
# OpenAI API Base URL (default)
# To use a local TTS model server, I suggest using https://github.com/remsky/Kokoro-FastAPI
API_BASE=https://api.openai.com/v1
API_BASE=https://api.openai.com/v1
# Path to your local whisper.cpp CLI binary
WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli

View file

@ -9,9 +9,9 @@ test.describe('API health checks', () => {
expect(json.voices.length).toBeGreaterThan(0);
});
test('GET /api/audio/convert/chapters returns 200 with exists flag and chapters array', async ({ request }) => {
test('GET /api/audiobook/status returns 200 with exists flag and chapters array', async ({ request }) => {
const bookId = `healthcheck-${Date.now()}`;
const res = await request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
const res = await request.get(`/api/audiobook/status?bookId=${bookId}`);
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json).toHaveProperty('exists');

View file

@ -67,7 +67,7 @@ async function getAudioDurationSeconds(filePath: string) {
}
async function expectChaptersBackendState(page: Page, bookId: string) {
const res = await page.request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`);
expect(res.ok()).toBeTruthy();
const json = await res.json();
return json;
@ -271,7 +271,7 @@ test.describe('Audiobook export', () => {
).toBeVisible({ timeout: 60_000 });
// Backend should report no existing chapters for this bookId
const res = await page.request.get(`/api/audio/convert/chapters?bookId=${bookId}`);
const res = await page.request.get(`/api/audiobook/status?bookId=${bookId}`);
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json.exists).toBe(false);

View file

@ -199,7 +199,7 @@ export async function openSettingsDocumentsTab(page: Page) {
// Delete all local documents through Settings and close dialogs
export async function deleteAllLocalDocuments(page: Page) {
await openSettingsDocumentsTab(page);
await page.getByRole('button', { name: 'Delete local docs' }).click();
await page.getByRole('button', { name: 'Delete local' }).click();
const heading = page.getByRole('heading', { name: 'Delete Local Documents' });
await expect(heading).toBeVisible({ timeout: 10000 });

View file

@ -43,9 +43,9 @@ test.describe('Document Upload Tests', () => {
test('displays an EPUB document', async ({ page }) => {
await uploadAndDisplay(page, 'sample.epub');
await expectViewerForFile(page, 'sample.epub');
// Keep navigation button assertions
await expect(page.getByRole('button', { name: '' })).toBeVisible();
await expect(page.getByRole('button', { name: '' })).toBeVisible();
// Navigation controls should be exposed via accessible labels
await expect(page.getByRole('button', { name: 'Previous section' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Next section' })).toBeVisible();
});
test('displays a DOCX document as PDF after conversion', async ({ page }) => {
@ -126,4 +126,4 @@ test.describe('Document Upload Tests', () => {
// Also ensure no link with that filename exists
await expect(page.getByRole('link', { name: /unsupported\.xyz/i })).toHaveCount(0);
});
});
});