commit
1c6f628c10
46 changed files with 2404 additions and 906 deletions
46
Dockerfile
46
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
64
README.md
64
README.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "openreader-webui",
|
||||
"version": "v1.0.1",
|
||||
"version": "v1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 3003",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
450
src/app/api/whisper/route.ts
Normal file
450
src/app/api/whisper/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
32
src/components/CodeBlock.tsx
Normal file
32
src/components/CodeBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
205
src/lib/client.ts
Normal 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();
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
407
src/lib/pdf.ts
407
src/lib/pdf.ts
|
|
@ -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
67
src/types/client.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -63,4 +63,5 @@ export interface DocumentListState {
|
|||
folders: Folder[];
|
||||
collapsedFolders: string[];
|
||||
showHint: boolean;
|
||||
viewMode?: 'list' | 'grid';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
|
|
@ -67,6 +67,9 @@ export default {
|
|||
},
|
||||
},
|
||||
},
|
||||
screens: {
|
||||
xs: '410px', // custom xs breakpoint
|
||||
},
|
||||
},
|
||||
plugins: [typography],
|
||||
} satisfies Config;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue