commit
1c6f628c10
46 changed files with 2404 additions and 906 deletions
46
Dockerfile
46
Dockerfile
|
|
@ -1,8 +1,18 @@
|
||||||
# Use Node.js slim image
|
# Stage 1: build whisper.cpp (no model download – the app handles that)
|
||||||
FROM node:current-alpine
|
FROM alpine:3.20 AS whisper-builder
|
||||||
|
|
||||||
# Add ffmpeg and libreoffice using Alpine package manager
|
RUN apk add --no-cache git cmake build-base
|
||||||
RUN apk add --no-cache ffmpeg libreoffice-writer
|
|
||||||
|
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
|
# Install pnpm globally
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
|
|
@ -23,8 +33,34 @@ COPY . .
|
||||||
RUN pnpm exec next telemetry disable
|
RUN pnpm exec next telemetry disable
|
||||||
RUN pnpm build
|
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 the port the app runs on
|
||||||
EXPOSE 3003
|
EXPOSE 3003
|
||||||
|
|
||||||
# Start the application
|
# 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)
|
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**
|
- 🎯 *(New)* **Multi-Provider TTS Support**
|
||||||
- [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI): Supporting multi-voice combinations (like `af_heart+af_bella`)
|
- [**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)
|
- [**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)**
|
- **Cloud TTS Providers (requiring API keys)**
|
||||||
- [**Deepinfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M + models with support for cloned voices and more
|
- [**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
|
- [**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)
|
- 📖 *(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
|
- 🛜 **Optional Server-side documents** using backend `/docstore` for all users
|
||||||
- 🎨 **Customizable Experience**
|
- 🎨 **Customizable Experience**
|
||||||
- 🎨 Multiple app theme options
|
- 🎨 Multiple app theme options
|
||||||
- ⚙️ Various TTS and document handling settings
|
- ⚙️ Various TTS and document handling settings
|
||||||
- And more ...
|
- 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
|
## 🐳 Docker Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
@ -194,6 +154,20 @@ Optionally required for different features:
|
||||||
```bash
|
```bash
|
||||||
brew install libreoffice
|
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
|
### Steps
|
||||||
|
|
||||||
1. Clone the repository:
|
1. Clone the repository:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "openreader-webui",
|
"name": "openreader-webui",
|
||||||
"version": "v1.0.1",
|
"version": "v1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 3003",
|
"dev": "next dev --turbopack -p 3003",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { spawn } from 'child_process';
|
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 { existsSync, createReadStream } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
|
||||||
|
|
||||||
interface ConversionRequest {
|
interface ConversionRequest {
|
||||||
chapterTitle: string;
|
chapterTitle: string;
|
||||||
buffer: number[];
|
buffer: TTSAudioBytes;
|
||||||
bookId?: string;
|
bookId?: string;
|
||||||
format?: 'mp3' | 'm4b';
|
format?: TTSAudiobookFormat;
|
||||||
chapterIndex?: number;
|
chapterIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,9 +207,12 @@ export async function POST(request: NextRequest) {
|
||||||
await unlink(inputPath).catch(console.error);
|
await unlink(inputPath).catch(console.error);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
index: chapterIndex,
|
||||||
|
title: data.chapterTitle,
|
||||||
|
duration,
|
||||||
|
status: 'completed' as const,
|
||||||
bookId,
|
bookId,
|
||||||
chapterIndex,
|
format
|
||||||
duration
|
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -229,7 +233,7 @@ export async function POST(request: NextRequest) {
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const bookId = request.nextUrl.searchParams.get('bookId');
|
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) {
|
if (!bookId) {
|
||||||
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
@ -378,4 +382,31 @@ function streamFile(filePath: string, format: string) {
|
||||||
'Cache-Control': 'no-cache',
|
'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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import { readdir, readFile, rm } from 'fs/promises';
|
import { readdir, readFile } from 'fs/promises';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -26,7 +27,7 @@ export async function GET(request: NextRequest) {
|
||||||
duration?: number;
|
duration?: number;
|
||||||
status: 'completed' | 'error';
|
status: 'completed' | 'error';
|
||||||
bookId: string;
|
bookId: string;
|
||||||
format?: 'mp3' | 'm4b';
|
format?: TTSAudiobookFormat;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const metaFile of metaFiles) {
|
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 { isKokoroModel } from '@/utils/voice';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import { createHash } from 'crypto';
|
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';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
|
@ -13,7 +14,7 @@ type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
||||||
voice: SpeechCreateParams['voice'] | CustomVoice;
|
voice: SpeechCreateParams['voice'] | CustomVoice;
|
||||||
instructions?: string;
|
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_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
|
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 = {
|
type InflightEntry = {
|
||||||
promise: Promise<ArrayBuffer>;
|
promise: Promise<TTSAudioBuffer>;
|
||||||
controller: AbortController;
|
controller: AbortController;
|
||||||
consumers: number;
|
consumers: number;
|
||||||
};
|
};
|
||||||
|
|
@ -40,7 +41,7 @@ async function fetchTTSBufferWithRetry(
|
||||||
openai: OpenAI,
|
openai: OpenAI,
|
||||||
createParams: ExtendedSpeechParams,
|
createParams: ExtendedSpeechParams,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<ArrayBuffer> {
|
): Promise<TTSAudioBuffer> {
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
|
const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2);
|
||||||
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
|
let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250);
|
||||||
|
|
@ -135,7 +136,7 @@ export async function POST(req: NextRequest) {
|
||||||
voice: normalizedVoice,
|
voice: normalizedVoice,
|
||||||
input: text,
|
input: text,
|
||||||
speed: speed,
|
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
|
// Only add instructions if model is gpt-4o-mini-tts and instructions are provided
|
||||||
if ((model as string) === 'gpt-4o-mini-tts' && instructions) {
|
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
|
// 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)
|
// Preserve voice string as-is for cache key (no weight stripping)
|
||||||
const voiceForKey = typeof createParams.voice === 'string'
|
const voiceForKey = typeof createParams.voice === 'string'
|
||||||
|
|
@ -245,7 +246,7 @@ export async function POST(req: NextRequest) {
|
||||||
};
|
};
|
||||||
req.signal.addEventListener('abort', onAbort, { once: true });
|
req.signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
|
||||||
let buffer: ArrayBuffer;
|
let buffer: TTSAudioBuffer;
|
||||||
try {
|
try {
|
||||||
buffer = await entry.promise;
|
buffer = await entry.promise;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -280,4 +281,4 @@ export async function POST(req: NextRequest) {
|
||||||
{ status: 500 }
|
{ 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 { ZoomControl } from '@/components/ZoomControl';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { DownloadIcon } from '@/components/icons/Icons';
|
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;
|
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 (
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal: AbortSignal,
|
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,
|
||||||
format: 'mp3' | 'm4b'
|
format: TTSAudiobookFormat
|
||||||
) => {
|
) => {
|
||||||
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
}, [createEPUBAudioBook, id]);
|
}, [createEPUBAudioBook, id]);
|
||||||
|
|
@ -95,7 +96,7 @@ export default function EPUBPage() {
|
||||||
const handleRegenerateChapter = useCallback(async (
|
const handleRegenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
return regenerateEPUBChapter(chapterIndex, bookId, format, signal);
|
||||||
|
|
@ -190,4 +191,4 @@ export default function EPUBPage() {
|
||||||
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
<DocumentSettings epub isOpen={isSettingsOpen} setIsOpen={setIsSettingsOpen} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { HomeContent } from '@/components/HomeContent';
|
import { HomeContent } from '@/components/HomeContent';
|
||||||
import { SettingsModal } from '@/components/SettingsModal';
|
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() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -10,9 +10,9 @@ export default function Home() {
|
||||||
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
<section className="px-4 pt-6 pb-4 md:pt-10 md:pb-6">
|
||||||
<div className="max-w-5xl mx-auto">
|
<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>
|
<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">
|
<p className="text-sm leading-relaxed max-w-[77ch] text-foreground">
|
||||||
Bring your own text-to-speech API.
|
Open source document reader {isDev ? 'self-hosted server' : 'demo app'}.
|
||||||
<span className="block font-medium">Read & listen to PDF, EPUB & HTML documents with high quality voices.</span>
|
<span className="block font-medium">Read & listen to PDF, EPUB, MD, and TXT documents with high quality text to speech voices.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { SettingsIcon, DownloadIcon } from '@/components/icons/Icons';
|
||||||
import { Header } from '@/components/Header';
|
import { Header } from '@/components/Header';
|
||||||
import { ZoomControl } from '@/components/ZoomControl';
|
import { ZoomControl } from '@/components/ZoomControl';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import TTSPlayer from '@/components/player/TTSPlayer';
|
import TTSPlayer from '@/components/player/TTSPlayer';
|
||||||
|
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
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 (
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal: AbortSignal,
|
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,
|
||||||
format: 'mp3' | 'm4b'
|
format: TTSAudiobookFormat
|
||||||
) => {
|
) => {
|
||||||
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
return createPDFAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
}, [createPDFAudioBook, id]);
|
}, [createPDFAudioBook, id]);
|
||||||
|
|
@ -89,7 +90,7 @@ export default function PDFViewerPage() {
|
||||||
const handleRegenerateChapter = useCallback(async (
|
const handleRegenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
return regeneratePDFChapter(chapterIndex, bookId, format, signal);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,14 @@ import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIco
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
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 {
|
interface AudiobookExportModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
|
@ -19,12 +26,12 @@ interface AudiobookExportModalProps {
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
onChapterComplete: (chapter: TTSAudiobookChapter) => void,
|
||||||
format: 'mp3' | 'm4b'
|
format: TTSAudiobookFormat
|
||||||
) => Promise<string>; // Returns bookId
|
) => Promise<string>; // Returns bookId
|
||||||
onRegenerateChapter?: (
|
onRegenerateChapter?: (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => Promise<TTSAudiobookChapter>;
|
) => Promise<TTSAudiobookChapter>;
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +53,7 @@ export function AudiobookExportModal({
|
||||||
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
|
const [isLoadingExisting, setIsLoadingExisting] = useState(false);
|
||||||
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
|
const [isRefreshingChapters, setIsRefreshingChapters] = useState(false);
|
||||||
const [currentChapter, setCurrentChapter] = useState<string>('');
|
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 [regeneratingChapter, setRegeneratingChapter] = useState<number | null>(null);
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
|
const [pendingDeleteChapter, setPendingDeleteChapter] = useState<TTSAudiobookChapter | null>(null);
|
||||||
|
|
@ -61,26 +68,23 @@ export function AudiobookExportModal({
|
||||||
setIsLoadingExisting(true);
|
setIsLoadingExisting(true);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audio/convert/chapters?bookId=${documentId}`);
|
const data = await getAudiobookStatus(documentId);
|
||||||
if (response.ok) {
|
if (data.exists && data.chapters.length > 0) {
|
||||||
const data = await response.json();
|
setChapters(data.chapters);
|
||||||
if (data.exists && data.chapters.length > 0) {
|
setBookId(data.bookId);
|
||||||
setChapters(data.chapters);
|
// Set format from existing chapters - this ensures the format matches what was actually generated
|
||||||
setBookId(data.bookId);
|
if (data.chapters[0]?.format) {
|
||||||
// Set format from existing chapters - this ensures the format matches what was actually generated
|
const detectedFormat = data.chapters[0].format as TTSAudiobookFormat;
|
||||||
if (data.chapters[0]?.format) {
|
setFormat(detectedFormat);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Error fetching existing chapters:', error);
|
console.error('Error fetching existing chapters:', error);
|
||||||
|
|
@ -241,12 +245,7 @@ export function AudiobookExportModal({
|
||||||
const performDeleteChapter = useCallback(async () => {
|
const performDeleteChapter = useCallback(async () => {
|
||||||
if (!bookId || !pendingDeleteChapter) return;
|
if (!bookId || !pendingDeleteChapter) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audio/convert/chapter?bookId=${bookId}&chapterIndex=${pendingDeleteChapter.index}`, {
|
await deleteAudiobookChapter(bookId, pendingDeleteChapter.index);
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Delete failed');
|
|
||||||
}
|
|
||||||
setChapters(prev => prev.filter(c => c.index !== pendingDeleteChapter.index));
|
setChapters(prev => prev.filter(c => c.index !== pendingDeleteChapter.index));
|
||||||
await fetchExistingChapters(true);
|
await fetchExistingChapters(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -261,10 +260,7 @@ export function AudiobookExportModal({
|
||||||
const targetBookId = bookId || documentId;
|
const targetBookId = bookId || documentId;
|
||||||
if (!targetBookId) return;
|
if (!targetBookId) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/audio/convert/chapters?bookId=${targetBookId}`, { method: 'DELETE' });
|
await deleteAudiobook(targetBookId);
|
||||||
if (!resp.ok) {
|
|
||||||
throw new Error('Reset failed');
|
|
||||||
}
|
|
||||||
setChapters([]);
|
setChapters([]);
|
||||||
setBookId(null);
|
setBookId(null);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
|
|
@ -281,10 +277,7 @@ export function AudiobookExportModal({
|
||||||
if (!chapter.bookId) return;
|
if (!chapter.bookId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audio/convert/chapter?bookId=${chapter.bookId}&chapterIndex=${chapter.index}`);
|
const blob = await downloadAudiobookChapter(chapter.bookId, chapter.index);
|
||||||
if (!response.ok) throw new Error('Download failed');
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|
@ -306,8 +299,7 @@ export function AudiobookExportModal({
|
||||||
|
|
||||||
setIsCombining(true);
|
setIsCombining(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/audio/convert?bookId=${bookId}&format=${format}`);
|
const response = await downloadAudiobook(bookId, format);
|
||||||
if (!response.ok) throw new Error('Download failed');
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
const reader = response.body?.getReader();
|
||||||
if (!reader) throw new Error('No response body');
|
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 { usePDF } from '@/contexts/PDFContext';
|
||||||
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { useParams } from 'next/navigation';
|
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;
|
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,
|
updateConfigKey,
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled,
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
|
pdfWordHighlightEnabled,
|
||||||
|
epubWordHighlightEnabled,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
|
const { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();
|
||||||
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
|
const { createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();
|
||||||
|
|
@ -78,8 +81,8 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
const handleGenerateAudiobook = useCallback(async (
|
const handleGenerateAudiobook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal: AbortSignal,
|
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,
|
||||||
format: 'mp3' | 'm4b'
|
format: TTSAudiobookFormat
|
||||||
) => {
|
) => {
|
||||||
if (epub) {
|
if (epub) {
|
||||||
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
return createEPUBAudioBook(onProgress, signal, onChapterComplete, id as string, format);
|
||||||
|
|
@ -91,7 +94,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
const handleRegenerateChapter = useCallback(async (
|
const handleRegenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
) => {
|
) => {
|
||||||
if (epub) {
|
if (epub) {
|
||||||
|
|
@ -138,16 +141,18 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
leaveTo="opacity-0 scale-95"
|
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">
|
<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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-full inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
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
|
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
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)}
|
onClick={() => setIsAudiobookModalOpen(true)}
|
||||||
|
disabled={!isDev}
|
||||||
>
|
>
|
||||||
Export Audiobook
|
Export Audiobook {!isDev && '(requires self-hosted)'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
|
|
@ -324,40 +329,82 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-muted pl-6">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!epub && !html && (
|
{!epub && !html && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center space-x-2">
|
<div className="space-y-1">
|
||||||
<input
|
<label className="flex items-center space-x-2">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={pdfHighlightEnabled}
|
type="checkbox"
|
||||||
onChange={(e) => updateConfigKey('pdfHighlightEnabled', e.target.checked)}
|
checked={pdfHighlightEnabled}
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
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>
|
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
|
||||||
<p className="text-sm text-muted pl-6">
|
</label>
|
||||||
Show visual highlighting in the PDF viewer while TTS is reading.
|
<p className="text-sm text-muted pl-6">
|
||||||
</p>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{epub && (
|
{epub && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<label className="flex items-center space-x-2">
|
<div className="space-y-1">
|
||||||
<input
|
<label className="flex items-center space-x-2">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={epubHighlightEnabled}
|
type="checkbox"
|
||||||
onChange={(e) => updateConfigKey('epubHighlightEnabled', e.target.checked)}
|
checked={epubHighlightEnabled}
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
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>
|
<span className="text-sm font-medium text-foreground">Highlight text during playback</span>
|
||||||
<p className="text-sm text-muted pl-6">
|
</label>
|
||||||
Show visual highlighting in the EPUB viewer while TTS is reading.
|
<p className="text-sm text-muted pl-6">
|
||||||
</p>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{epub && (
|
{epub && (
|
||||||
|
|
@ -369,10 +416,10 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
|
||||||
onChange={(e) => updateConfigKey('epubTheme', e.target.checked)}
|
onChange={(e) => updateConfigKey('epubTheme', e.target.checked)}
|
||||||
className="form-checkbox h-4 w-4 text-accent rounded border-muted"
|
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>
|
</label>
|
||||||
<p className="text-sm text-muted pl-6">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useCallback } from 'react';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import { UploadIcon } from '@/components/icons/Icons';
|
import { UploadIcon } from '@/components/icons/Icons';
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
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;
|
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 [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const convertDocxToPdf = async (file: File): Promise<File> => {
|
const convertDocxToPdf = async (file: File): Promise<File> => {
|
||||||
const formData = new FormData();
|
const pdfBlob = await convertDocxToPdfClient(file);
|
||||||
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();
|
|
||||||
return new File([pdfBlob], file.name.replace(/\.docx$/, '.pdf'), {
|
return new File([pdfBlob], file.name.replace(/\.docx$/, '.pdf'), {
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useEPUB } from '@/contexts/EPUBContext';
|
import { useEPUB } from '@/contexts/EPUBContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
|
|
@ -8,6 +8,7 @@ import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
import { DocumentSkeleton } from '@/components/DocumentSkeleton';
|
||||||
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
import { useEPUBTheme, getThemeStyles } from '@/hooks/epub/useEPUBTheme';
|
||||||
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
import { useEPUBResize } from '@/hooks/epub/useEPUBResize';
|
||||||
|
import { DotsVerticalIcon, ChevronLeftIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
||||||
|
|
||||||
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
|
const ReactReader = dynamic(() => import('react-reader').then(mod => mod.ReactReader), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -19,9 +20,12 @@ interface EPUBViewerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
|
const [isTocOpen, setIsTocOpen] = useState(false);
|
||||||
const {
|
const {
|
||||||
currDocData,
|
currDocData,
|
||||||
currDocName,
|
currDocName,
|
||||||
|
currDocPage,
|
||||||
|
currDocPages,
|
||||||
locationRef,
|
locationRef,
|
||||||
handleLocationChanged,
|
handleLocationChanged,
|
||||||
bookRef,
|
bookRef,
|
||||||
|
|
@ -30,10 +34,18 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
setRendition,
|
setRendition,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights
|
clearHighlights,
|
||||||
|
highlightWordIndex,
|
||||||
|
clearWordHighlights
|
||||||
} = useEPUB();
|
} = useEPUB();
|
||||||
const { registerLocationChangeHandler, pause, currentSentence } = useTTS();
|
const {
|
||||||
const { epubTheme } = useConfig();
|
registerLocationChangeHandler,
|
||||||
|
pause,
|
||||||
|
currentSentence,
|
||||||
|
currentSentenceAlignment,
|
||||||
|
currentWordIndex
|
||||||
|
} = useTTS();
|
||||||
|
const { epubTheme, epubHighlightEnabled, epubWordHighlightEnabled } = useConfig();
|
||||||
const { updateTheme } = useEPUBTheme(epubTheme, renditionRef.current);
|
const { updateTheme } = useEPUBTheme(epubTheme, renditionRef.current);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const { isResizing, setIsResizing, dimensions } = useEPUBResize(containerRef);
|
const { isResizing, setIsResizing, dimensions } = useEPUBResize(containerRef);
|
||||||
|
|
@ -70,13 +82,103 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
}
|
}
|
||||||
}, [currentSentence, highlightPattern, clearHighlights]);
|
}, [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) {
|
if (!currDocData) {
|
||||||
return <DocumentSkeleton />;
|
return <DocumentSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-full flex flex-col relative z-0 ${className}`} ref={containerRef}>
|
<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
|
<ReactReader
|
||||||
loadingView={<DocumentSkeleton />}
|
loadingView={<DocumentSkeleton />}
|
||||||
key={'epub-reader'}
|
key={'epub-reader'}
|
||||||
|
|
@ -85,8 +187,8 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
url={currDocData}
|
url={currDocData}
|
||||||
title={currDocName}
|
title={currDocName}
|
||||||
tocChanged={(_toc) => (tocRef.current = _toc)}
|
tocChanged={(_toc) => (tocRef.current = _toc)}
|
||||||
showToc={true}
|
showToc={false}
|
||||||
readerStyles={epubTheme && getThemeStyles() || undefined}
|
readerStyles={getThemeStyles(epubTheme)}
|
||||||
getRendition={(_rendition) => {
|
getRendition={(_rendition) => {
|
||||||
setRendition(_rendition);
|
setRendition(_rendition);
|
||||||
updateTheme();
|
updateTheme();
|
||||||
|
|
@ -95,4 +197,4 @@ export function EPUBViewer({ className = '' }: EPUBViewerProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
|
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
|
||||||
import { GithubIcon } from '@/components/icons/Icons'
|
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() {
|
export function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="m-8 text-sm text-muted">
|
<footer className="m-8 mb-2 text-sm text-muted">
|
||||||
<div className="flex flex-col items-center space-y-2">
|
<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">
|
<div className="flex flex-wrap sm:flex-nowrap items-center justify-center text-center sm:space-x-3">
|
||||||
<a
|
<a
|
||||||
href="https://github.com/richardr1126/OpenReader-WebUI"
|
href="https://github.com/richardr1126/OpenReader-WebUI"
|
||||||
|
|
@ -16,15 +20,16 @@ export function Footer() {
|
||||||
</a>
|
</a>
|
||||||
<span className='w-full sm:w-fit'>•</span>
|
<span className='w-full sm:w-fit'>•</span>
|
||||||
<Popover className="flex">
|
<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
|
Privacy info
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
<PopoverPanel anchor="top" className="bg-base p-4 rounded-lg shadow-lg w-64">
|
<PopoverPanel anchor="top" className="bg-base p-4 rounded-lg shadow-xl border border-offbase z-50">
|
||||||
<p>Documents are uploaded to your local browser cache.</p>
|
<p className='max-w-xs'>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 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'>The audio is streamed back to your browser and played in real-time.</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 */}
|
{/* 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>
|
</PopoverPanel>
|
||||||
</Popover>
|
</Popover>
|
||||||
<span className='w-full sm:w-fit'>•</span>
|
<span className='w-full sm:w-fit'>•</span>
|
||||||
|
|
@ -34,7 +39,7 @@ export function Footer() {
|
||||||
href="https://huggingface.co/hexgrad/Kokoro-82M"
|
href="https://huggingface.co/hexgrad/Kokoro-82M"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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
|
hexgrad/Kokoro-82M
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -43,12 +48,66 @@ export function Footer() {
|
||||||
href="https://deepinfra.com/models?type=text-to-speech"
|
href="https://deepinfra.com/models?type=text-to-speech"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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
|
Deepinfra
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,13 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
const { containerWidth } = usePDFResize(containerRef);
|
const { containerWidth } = usePDFResize(containerRef);
|
||||||
|
|
||||||
// Config context
|
// Config context
|
||||||
const { viewType, pdfHighlightEnabled } = useConfig();
|
const { viewType, pdfHighlightEnabled, pdfWordHighlightEnabled } = useConfig();
|
||||||
|
|
||||||
// TTS context
|
// TTS context
|
||||||
const {
|
const {
|
||||||
currentSentence,
|
currentSentence,
|
||||||
|
currentWordIndex,
|
||||||
|
currentSentenceAlignment,
|
||||||
skipToLocation,
|
skipToLocation,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
|
|
||||||
|
|
@ -38,6 +40,8 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
const {
|
const {
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
|
clearWordHighlights,
|
||||||
|
highlightWordIndex,
|
||||||
onDocumentLoadSuccess,
|
onDocumentLoadSuccess,
|
||||||
currDocData,
|
currDocData,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
|
|
@ -74,6 +78,46 @@ export function PDFViewer({ zoomLevel }: PDFViewerProps) {
|
||||||
};
|
};
|
||||||
}, [currDocText, currentSentence, highlightPattern, clearHighlights, pdfHighlightEnabled]);
|
}, [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
|
// Add page dimensions state
|
||||||
const [pageWidth, setPageWidth] = useState<number>(595); // default A4 width
|
const [pageWidth, setPageWidth] = useState<number>(595); // default A4 width
|
||||||
const [pageHeight, setPageHeight] = useState<number>(842); // default A4 height
|
const [pageHeight, setPageHeight] = useState<number>(842); // default A4 height
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||||
import { THEMES } from '@/contexts/ThemeContext';
|
import { THEMES } from '@/contexts/ThemeContext';
|
||||||
|
import { deleteServerDocuments } from '@/lib/client';
|
||||||
|
|
||||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null;
|
||||||
|
|
||||||
|
|
@ -222,12 +223,7 @@ export function SettingsModal() {
|
||||||
|
|
||||||
const handleClearServer = async () => {
|
const handleClearServer = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/documents', {
|
await deleteServerDocuments();
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to delete server documents');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Delete failed:', error);
|
console.error('Delete failed:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -404,7 +400,7 @@ export function SettingsModal() {
|
||||||
type="password"
|
type="password"
|
||||||
value={localApiKey}
|
value={localApiKey}
|
||||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
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"
|
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>
|
</div>
|
||||||
|
|
@ -603,7 +599,7 @@ export function SettingsModal() {
|
||||||
|
|
||||||
<TabPanel className="space-y-4">
|
<TabPanel className="space-y-4">
|
||||||
{isDev && <div className="space-y-1">
|
{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">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleLoad}
|
onClick={handleLoad}
|
||||||
|
|
@ -614,7 +610,7 @@ export function SettingsModal() {
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? `Loading... ${Math.round(progress)}%` : 'Load docs from Server'}
|
{isLoading ? `Loading... ${Math.round(progress)}%` : 'Load'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSync}
|
onClick={handleSync}
|
||||||
|
|
@ -625,13 +621,13 @@ export function SettingsModal() {
|
||||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||||
disabled:opacity-50"
|
disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isSyncing ? `Saving... ${Math.round(progress)}%` : 'Save local to Server'}
|
{isSyncing ? `Saving... ${Math.round(progress)}%` : 'Save to server'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div className="space-y-1 pb-3">
|
<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">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowClearLocalConfirm(true)}
|
onClick={() => setShowClearLocalConfirm(true)}
|
||||||
|
|
@ -640,7 +636,7 @@ export function SettingsModal() {
|
||||||
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
|
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]"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
>
|
>
|
||||||
Delete local docs
|
Delete local
|
||||||
</Button>
|
</Button>
|
||||||
{isDev && <Button
|
{isDev && <Button
|
||||||
onClick={() => setShowClearServerConfirm(true)}
|
onClick={() => setShowClearServerConfirm(true)}
|
||||||
|
|
@ -649,7 +645,7 @@ export function SettingsModal() {
|
||||||
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
|
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]"
|
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
|
||||||
>
|
>
|
||||||
Delete server docs
|
Delete server
|
||||||
</Button>}
|
</Button>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, DragEvent } from 'react';
|
import { useState, DragEvent } from 'react';
|
||||||
import { Button, Transition } from '@headlessui/react';
|
import { Button, Transition } from '@headlessui/react';
|
||||||
import { DocumentListItem } from './DocumentListItem';
|
import { DocumentListItem } from './DocumentListItem';
|
||||||
|
|
@ -16,6 +14,7 @@ interface DocumentFolderProps {
|
||||||
onDragStart: (doc: DocumentListDocument) => void;
|
onDragStart: (doc: DocumentListDocument) => void;
|
||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
onDrop: (e: DragEvent, folderId: string) => void;
|
onDrop: (e: DragEvent, folderId: string) => void;
|
||||||
|
viewMode: 'list' | 'grid';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChevronIcon = ({ className = "w-4 h-4" }) => (
|
const ChevronIcon = ({ className = "w-4 h-4" }) => (
|
||||||
|
|
@ -39,6 +38,7 @@ export function DocumentFolder({
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
onDrop,
|
onDrop,
|
||||||
|
viewMode,
|
||||||
}: DocumentFolderProps) {
|
}: DocumentFolderProps) {
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
const isDropTarget = isHovering && draggedDoc && !draggedDoc.folderId && draggedDoc.id !== folder.id;
|
const isDropTarget = isHovering && draggedDoc && !draggedDoc.folderId && draggedDoc.id !== folder.id;
|
||||||
|
|
@ -64,7 +64,7 @@ export function DocumentFolder({
|
||||||
if (!draggedDoc || draggedDoc.folderId) return;
|
if (!draggedDoc || draggedDoc.folderId) return;
|
||||||
onDrop(e, folder.id);
|
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='flex flex-row justify-between p-0'>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
|
@ -104,7 +104,7 @@ export function DocumentFolder({
|
||||||
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
leaveFrom="transform scale-y-100 opacity-100 max-h-[1000px]"
|
||||||
leaveTo="transform scale-y-0 opacity-0 max-h-0"
|
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 => (
|
{sortedDocuments.map(doc => (
|
||||||
<DocumentListItem
|
<DocumentListItem
|
||||||
key={`${doc.type}-${doc.id}`}
|
key={`${doc.type}-${doc.id}`}
|
||||||
|
|
@ -114,6 +114,7 @@ export function DocumentFolder({
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
isDropTarget={false}
|
isDropTarget={false}
|
||||||
|
viewMode={viewMode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ export function DocumentList() {
|
||||||
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
|
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(new Set());
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [showHint, setShowHint] = useState(true);
|
const [showHint, setShowHint] = useState(true);
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'grid'>('grid');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
pdfDocs,
|
pdfDocs,
|
||||||
|
|
@ -78,6 +79,7 @@ export function DocumentList() {
|
||||||
setFolders(savedState.folders);
|
setFolders(savedState.folders);
|
||||||
setCollapsedFolders(new Set(savedState.collapsedFolders));
|
setCollapsedFolders(new Set(savedState.collapsedFolders));
|
||||||
setShowHint(savedState.showHint ?? true); // Use saved hint state or default to true
|
setShowHint(savedState.showHint ?? true); // Use saved hint state or default to true
|
||||||
|
setViewMode(savedState.viewMode ?? 'grid');
|
||||||
}
|
}
|
||||||
setIsInitialized(true);
|
setIsInitialized(true);
|
||||||
};
|
};
|
||||||
|
|
@ -92,7 +94,8 @@ export function DocumentList() {
|
||||||
sortDirection,
|
sortDirection,
|
||||||
folders,
|
folders,
|
||||||
collapsedFolders: Array.from(collapsedFolders),
|
collapsedFolders: Array.from(collapsedFolders),
|
||||||
showHint
|
showHint,
|
||||||
|
viewMode
|
||||||
};
|
};
|
||||||
await saveDocumentListState(state);
|
await saveDocumentListState(state);
|
||||||
};
|
};
|
||||||
|
|
@ -100,7 +103,7 @@ export function DocumentList() {
|
||||||
if (isInitialized) { // Prevents saving empty state on first render or back navigation
|
if (isInitialized) { // Prevents saving empty state on first render or back navigation
|
||||||
saveState();
|
saveState();
|
||||||
}
|
}
|
||||||
}, [sortBy, sortDirection, folders, collapsedFolders, showHint, isInitialized]);
|
}, [sortBy, sortDirection, folders, collapsedFolders, showHint, viewMode, isInitialized]);
|
||||||
|
|
||||||
const allDocuments: DocumentListDocument[] = [
|
const allDocuments: DocumentListDocument[] = [
|
||||||
...pdfDocs.map(doc => ({
|
...pdfDocs.map(doc => ({
|
||||||
|
|
@ -315,6 +318,8 @@ export function DocumentList() {
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
onSortByChange={setSortBy}
|
onSortByChange={setSortBy}
|
||||||
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
onSortDirectionChange={() => setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc')}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onViewModeChange={setViewMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -325,21 +330,22 @@ export function DocumentList() {
|
||||||
<DocumentUploader variant="compact" />
|
<DocumentUploader variant="compact" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{showHint && allDocuments.length > 1 && (
|
||||||
{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">
|
||||||
<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>
|
||||||
<p className="text-sm text-foreground">Drag files on top of each other to make folders</p>
|
<Button
|
||||||
<Button
|
onClick={() => setShowHint(false)}
|
||||||
onClick={() => setShowHint(false)}
|
className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
|
||||||
className="p-1 rounded-md hover:bg-base hover:text-accent transition-colors"
|
aria-label="Dismiss hint"
|
||||||
aria-label="Dismiss hint"
|
>
|
||||||
>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<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" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
</svg>
|
||||||
</svg>
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
<div className={viewMode === 'grid' ? "flex flex-wrap gap-1 w-full" : "space-y-1 w-full"}>
|
||||||
|
|
||||||
{folders.map(folder => (
|
{folders.map(folder => (
|
||||||
<DocumentFolder
|
<DocumentFolder
|
||||||
|
|
@ -354,6 +360,7 @@ export function DocumentList() {
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDrop={handleFolderDrop}
|
onDrop={handleFolderDrop}
|
||||||
|
viewMode={viewMode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
@ -368,6 +375,7 @@ export function DocumentList() {
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
isDropTarget={dropTargetDoc?.id === doc.id}
|
isDropTarget={dropTargetDoc?.id === doc.id}
|
||||||
|
viewMode={viewMode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ interface DocumentListItemProps {
|
||||||
onDragLeave?: () => void;
|
onDragLeave?: () => void;
|
||||||
onDrop?: (e: DragEvent, doc: DocumentListDocument) => void;
|
onDrop?: (e: DragEvent, doc: DocumentListDocument) => void;
|
||||||
isDropTarget?: boolean;
|
isDropTarget?: boolean;
|
||||||
|
viewMode: 'list' | 'grid';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentListItem({
|
export function DocumentListItem({
|
||||||
|
|
@ -27,6 +28,7 @@ export function DocumentListItem({
|
||||||
onDragLeave,
|
onDragLeave,
|
||||||
onDrop,
|
onDrop,
|
||||||
isDropTarget = false,
|
isDropTarget = false,
|
||||||
|
viewMode,
|
||||||
}: DocumentListItemProps) {
|
}: DocumentListItemProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -51,18 +53,18 @@ export function DocumentListItem({
|
||||||
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
onDrop={(e) => allowDropTarget && onDrop?.(e, doc)}
|
||||||
aria-busy={loading}
|
aria-busy={loading}
|
||||||
className={`
|
className={`
|
||||||
w-full group
|
${viewMode === 'grid' ? 'flex-auto min-w-[200px] max-w-full' : 'w-full'} group
|
||||||
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
|
${allowDropTarget && isDropTarget ? 'ring-2 ring-accent' : ''}
|
||||||
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
|
${loading ? 'prism-outline' : 'bg-base hover:bg-offbase'}
|
||||||
border border-offbase rounded-md p-1
|
border border-offbase rounded-md p-1
|
||||||
transition-colors duration-150 relative
|
transition-colors duration-150 relative
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center w-full">
|
||||||
<Link
|
<Link
|
||||||
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
href={`/${doc.type}/${encodeURIComponent(doc.id)}`}
|
||||||
draggable={false}
|
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}
|
onClick={handleDocumentClick}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Button } from '@headlessui/react';
|
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';
|
import { SortBy, SortDirection } from '@/types/documents';
|
||||||
|
|
||||||
interface SortControlsProps {
|
interface SortControlsProps {
|
||||||
|
|
@ -7,6 +7,8 @@ interface SortControlsProps {
|
||||||
sortDirection: SortDirection;
|
sortDirection: SortDirection;
|
||||||
onSortByChange: (value: SortBy) => void;
|
onSortByChange: (value: SortBy) => void;
|
||||||
onSortDirectionChange: () => void;
|
onSortDirectionChange: () => void;
|
||||||
|
viewMode: 'list' | 'grid';
|
||||||
|
onViewModeChange: (mode: 'list' | 'grid') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SortControls({
|
export function SortControls({
|
||||||
|
|
@ -14,6 +16,8 @@ export function SortControls({
|
||||||
sortDirection,
|
sortDirection,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortDirectionChange,
|
onSortDirectionChange,
|
||||||
|
viewMode,
|
||||||
|
onViewModeChange,
|
||||||
}: SortControlsProps) {
|
}: SortControlsProps) {
|
||||||
const sortOptions: Array<{ value: SortBy; label: string, up: string, down: string }> = [
|
const sortOptions: Array<{ value: SortBy; label: string, up: string, down: string }> = [
|
||||||
{ value: 'name', label: 'Name', up: 'A-Z', down: 'Z-A' },
|
{ 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 currentSort = sortOptions.find(opt => opt.value === sortBy);
|
||||||
const directionLabel = sortDirection === 'asc' ? currentSort?.up : currentSort?.down;
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<div className="hidden xs:flex items-center bg-base rounded p-[1px] gap-0.5 border border-transparent">
|
||||||
onClick={onSortDirectionChange}
|
<Button
|
||||||
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"
|
onClick={() => onViewModeChange('list')}
|
||||||
>
|
className={`p-0.5 rounded hover:bg-offbase transition-all hover:scale-[1.07] ${viewMode === 'list' ? activeIconClass : inactiveIconClass}`}
|
||||||
{directionLabel}
|
aria-label="List view"
|
||||||
</Button>
|
>
|
||||||
<div className="relative">
|
<ListIcon className="w-4 h-4" />
|
||||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
</Button>
|
||||||
<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">
|
<Button
|
||||||
<span>{sortOptions.find(opt => opt.value === sortBy)?.label}</span>
|
onClick={() => onViewModeChange('grid')}
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
className={`p-0.5 rounded hover:bg-offbase transition-all hover:scale-[1.07] ${viewMode === 'grid' ? activeIconClass : inactiveIconClass}`}
|
||||||
</ListboxButton>
|
aria-label="Grid view"
|
||||||
<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) => (
|
<GridIcon className="w-4 h-4" />
|
||||||
<ListboxOption
|
</Button>
|
||||||
key={option.value}
|
</div>
|
||||||
value={option.value}
|
|
||||||
className={({ active, selected }) =>
|
<div className="hidden xs:block h-4 w-px bg-offbase mx-1" />
|
||||||
`relative cursor-pointer select-none py-0.5 px-1.5 sm:py-2 sm:px-3 ${active ? 'bg-offbase' : ''} ${selected ? 'font-medium' : ''}`
|
|
||||||
}
|
<div className="flex items-center gap-1">
|
||||||
>
|
<Button
|
||||||
<span className="text-xs sm:text-sm">{option.label}</span>
|
onClick={onSortDirectionChange}
|
||||||
</ListboxOption>
|
className={`${buttonBaseClass} px-2 text-xs`}
|
||||||
))}
|
>
|
||||||
</ListboxOptions>
|
{directionLabel}
|
||||||
</Listbox>
|
</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>
|
||||||
</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>) {
|
export function SpeedometerIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -414,3 +458,55 @@ export function AudioWaveIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||||
</svg>
|
</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;
|
skipToLocation: (location: string | number, shouldPause?: boolean) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInputValue(currentPage.toString());
|
setInputValue(currentPage.toString());
|
||||||
}, [currentPage]);
|
}, [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>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
// Only allow numbers
|
// Only allow numbers
|
||||||
const value = e.target.value.replace(/[^0-9]/g, '');
|
const value = e.target.value.replace(/[^0-9]/g, '');
|
||||||
|
|
@ -56,6 +43,11 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
|
|
||||||
const handlePopoverOpen = () => {
|
const handlePopoverOpen = () => {
|
||||||
setInputValue(''); // Clear input when popup opens
|
setInputValue(''); // Clear input when popup opens
|
||||||
|
// Auto-focus and select input shortly after opening
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -73,44 +65,34 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Page number popup */}
|
{/* Page number popup */}
|
||||||
<Popover className="relative">
|
<Popover className="relative mb-1">
|
||||||
{({ open }) => {
|
<PopoverButton
|
||||||
if (open !== isPopoverOpen) {
|
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"
|
||||||
setIsPopoverOpen(open);
|
onClick={handlePopoverOpen}
|
||||||
}
|
>
|
||||||
|
<p className="text-xs whitespace-nowrap">
|
||||||
return (
|
{currentPage} / {numPages || 1}
|
||||||
<>
|
</p>
|
||||||
<PopoverButton
|
</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"
|
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase">
|
||||||
onClick={handlePopoverOpen}
|
<div className="flex flex-col space-y-2">
|
||||||
>
|
<div className="text-xs font-medium text-foreground">Go to page</div>
|
||||||
<p className="text-xs whitespace-nowrap">
|
<input
|
||||||
{currentPage} / {numPages || 1}
|
ref={inputRef}
|
||||||
</p>
|
type="text"
|
||||||
</PopoverButton>
|
inputMode="numeric"
|
||||||
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase">
|
pattern="[0-9]*"
|
||||||
<div className="flex flex-col space-y-2">
|
className="w-20 px-2 py-1 text-xs text-accent bg-offbase rounded border-none outline-none appearance-none text-center"
|
||||||
<div className="text-xs font-medium text-foreground">Go to page</div>
|
value={inputValue}
|
||||||
<input
|
onChange={handleInputChange}
|
||||||
ref={inputRef}
|
onBlur={handleInputConfirm}
|
||||||
type="text"
|
onKeyDown={handleInputKeyDown}
|
||||||
inputMode="numeric"
|
placeholder={currentPage.toString()}
|
||||||
pattern="[0-9]*"
|
aria-label="Page number"
|
||||||
className="w-20 px-2 py-1 text-xs text-accent bg-offbase rounded border-none outline-none appearance-none text-center"
|
/>
|
||||||
value={inputValue}
|
<div className="text-xs text-muted text-center">of {numPages || 1}</div>
|
||||||
onChange={handleInputChange}
|
</div>
|
||||||
onBlur={handleInputConfirm}
|
</PopoverPanel>
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
placeholder={currentPage.toString()}
|
|
||||||
aria-label="Page number"
|
|
||||||
/>
|
|
||||||
<div className="text-xs text-muted text-center">of {numPages || 1}</div>
|
|
||||||
</div>
|
|
||||||
</PopoverPanel>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Page forward */}
|
{/* Page forward */}
|
||||||
|
|
@ -126,4 +108,4 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ interface ConfigContextType {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isDBReady: boolean;
|
isDBReady: boolean;
|
||||||
pdfHighlightEnabled: boolean;
|
pdfHighlightEnabled: boolean;
|
||||||
|
pdfWordHighlightEnabled: boolean;
|
||||||
epubHighlightEnabled: boolean;
|
epubHighlightEnabled: boolean;
|
||||||
|
epubWordHighlightEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
|
||||||
|
|
@ -103,7 +105,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
savedVoices,
|
savedVoices,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled,
|
||||||
|
pdfWordHighlightEnabled,
|
||||||
epubHighlightEnabled,
|
epubHighlightEnabled,
|
||||||
|
epubWordHighlightEnabled,
|
||||||
} = config || APP_CONFIG_DEFAULTS;
|
} = config || APP_CONFIG_DEFAULTS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -201,7 +205,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||||
isLoading,
|
isLoading,
|
||||||
isDBReady,
|
isDBReady,
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled,
|
||||||
epubHighlightEnabled
|
pdfWordHighlightEnabled,
|
||||||
|
epubHighlightEnabled,
|
||||||
|
epubWordHighlightEnabled
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ConfigContext.Provider>
|
</ConfigContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,18 @@ import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { createRangeCfi } from '@/lib/epub';
|
import { createRangeCfi } from '@/lib/epub';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useConfig } from './ConfigContext';
|
import { useConfig } from './ConfigContext';
|
||||||
import { withRetry } from '@/utils/audio';
|
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
|
||||||
import type { TTSRequestHeaders, TTSRequestPayload, TTSRetryOptions } from '@/types/tts';
|
import { CmpStr } from 'cmpstr';
|
||||||
|
import type {
|
||||||
|
TTSSentenceAlignment,
|
||||||
|
TTSAudiobookFormat,
|
||||||
|
TTSAudiobookChapter,
|
||||||
|
} from '@/types/tts';
|
||||||
|
import type {
|
||||||
|
TTSRequestHeaders,
|
||||||
|
TTSRequestPayload,
|
||||||
|
TTSRetryOptions,
|
||||||
|
} from '@/types/client';
|
||||||
|
|
||||||
interface EPUBContextType {
|
interface EPUBContextType {
|
||||||
currDocData: ArrayBuffer | undefined;
|
currDocData: ArrayBuffer | undefined;
|
||||||
|
|
@ -33,8 +43,8 @@ interface EPUBContextType {
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
extractPageText: (book: Book, rendition: Rendition, shouldPause?: boolean) => Promise<string>;
|
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>;
|
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => 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' }>;
|
regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>;
|
||||||
bookRef: RefObject<Book | null>;
|
bookRef: RefObject<Book | null>;
|
||||||
renditionRef: RefObject<Rendition | undefined>;
|
renditionRef: RefObject<Rendition | undefined>;
|
||||||
tocRef: RefObject<NavItem[]>;
|
tocRef: RefObject<NavItem[]>;
|
||||||
|
|
@ -44,12 +54,26 @@ interface EPUBContextType {
|
||||||
isAudioCombining: boolean;
|
isAudioCombining: boolean;
|
||||||
highlightPattern: (text: string) => void;
|
highlightPattern: (text: string) => void;
|
||||||
clearHighlights: () => 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 EPUBContext = createContext<EPUBContextType | undefined>(undefined);
|
||||||
|
|
||||||
const EPUB_CONTINUATION_CHARS = 600;
|
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 => {
|
const stepToNextNode = (node: Node | null, root: Node): Node | null => {
|
||||||
if (!node) return null;
|
if (!node) return null;
|
||||||
if (node.firstChild) {
|
if (node.firstChild) {
|
||||||
|
|
@ -160,6 +184,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const shouldPauseRef = useRef(true);
|
const shouldPauseRef = useRef(true);
|
||||||
// Track current highlight CFI for removal
|
// Track current highlight CFI for removal
|
||||||
const currentHighlightCfi = useRef<string | null>(null);
|
const currentHighlightCfi = useRef<string | null>(null);
|
||||||
|
const currentWordHighlightCfi = useRef<string | null>(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears all current document state and stops any active TTS
|
* Clears all current document state and stops any active TTS
|
||||||
|
|
@ -298,9 +323,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const createFullAudioBook = useCallback(async (
|
const createFullAudioBook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal?: AbortSignal,
|
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,
|
providedBookId?: string,
|
||||||
format: 'mp3' | 'm4b' = 'mp3'
|
format: TTSAudiobookFormat = 'mp3'
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const sections = await extractBookText();
|
const sections = await extractBookText();
|
||||||
|
|
@ -318,18 +343,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const existingIndices = new Set<number>();
|
const existingIndices = new Set<number>();
|
||||||
if (bookId) {
|
if (bookId) {
|
||||||
try {
|
try {
|
||||||
const existingResponse = await fetch(`/api/audio/convert/chapters?bookId=${bookId}`);
|
const existingData = await getAudiobookStatus(bookId);
|
||||||
if (existingResponse.ok) {
|
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||||
const existingData = await existingResponse.json();
|
for (const ch of existingData.chapters) {
|
||||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
existingIndices.add(ch.index);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Error checking existing chapters:', error);
|
console.error('Error checking existing chapters:', error);
|
||||||
|
|
@ -410,22 +432,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
throw new DOMException('Aborted', 'AbortError');
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
return await generateTTS(reqBody, reqHeaders, signal);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
retryOptions
|
retryOptions
|
||||||
);
|
);
|
||||||
|
|
@ -448,45 +455,21 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to server for conversion and storage
|
// Send to server for conversion and storage
|
||||||
const convertResponse = await fetch('/api/audio/convert', {
|
const chapter = await createAudiobookChapter({
|
||||||
method: 'POST',
|
chapterTitle,
|
||||||
headers: {
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
'Content-Type': 'application/json',
|
bookId,
|
||||||
},
|
format,
|
||||||
body: JSON.stringify({
|
chapterIndex: i
|
||||||
chapterTitle,
|
}, signal);
|
||||||
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();
|
|
||||||
|
|
||||||
if (!bookId) {
|
if (!bookId) {
|
||||||
bookId = returnedBookId;
|
bookId = chapter.bookId!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify about completed chapter
|
// Notify about completed chapter
|
||||||
if (onChapterComplete) {
|
if (onChapterComplete) {
|
||||||
onChapterComplete({
|
onChapterComplete(chapter);
|
||||||
index: chapterIndex,
|
|
||||||
title: chapterTitle,
|
|
||||||
duration,
|
|
||||||
status: 'completed',
|
|
||||||
bookId,
|
|
||||||
format
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
processedLength += trimmedText.length;
|
processedLength += trimmedText.length;
|
||||||
|
|
@ -532,9 +515,9 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
const regenerateChapter = useCallback(async (
|
const regenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
): Promise<TTSAudiobookChapter> => {
|
||||||
try {
|
try {
|
||||||
const sections = await extractBookText();
|
const sections = await extractBookText();
|
||||||
if (chapterIndex >= sections.length) {
|
if (chapterIndex >= sections.length) {
|
||||||
|
|
@ -599,22 +582,7 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
throw new DOMException('Aborted', 'AbortError');
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
return await generateTTS(reqBody, reqHeaders, signal);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
retryOptions
|
retryOptions
|
||||||
);
|
);
|
||||||
|
|
@ -624,39 +592,15 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to server for conversion and storage
|
// Send to server for conversion and storage
|
||||||
const convertResponse = await fetch('/api/audio/convert', {
|
const chapter = await createAudiobookChapter({
|
||||||
method: 'POST',
|
chapterTitle,
|
||||||
headers: {
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
'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',
|
|
||||||
bookId,
|
bookId,
|
||||||
format
|
format,
|
||||||
};
|
chapterIndex
|
||||||
|
}, signal);
|
||||||
|
|
||||||
|
return chapter;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
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]);
|
}, [id, skipToLocation, extractPageText, setIsEPUB]);
|
||||||
|
|
||||||
const clearHighlights = useCallback(() => {
|
const clearWordHighlights = useCallback(() => {
|
||||||
if (renditionRef.current) {
|
if (!renditionRef.current) return;
|
||||||
if (currentHighlightCfi.current) {
|
if (currentWordHighlightCfi.current) {
|
||||||
renditionRef.current.annotations.remove(currentHighlightCfi.current, 'highlight');
|
renditionRef.current.annotations.remove(currentWordHighlightCfi.current, 'highlight');
|
||||||
currentHighlightCfi.current = null;
|
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) => {
|
const highlightPattern = useCallback(async (text: string) => {
|
||||||
if (!renditionRef.current) return;
|
if (!renditionRef.current) return;
|
||||||
|
|
||||||
|
|
@ -772,6 +723,262 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
}, [epubHighlightEnabled, clearHighlights]);
|
}, [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
|
// Context value memoization
|
||||||
|
|
@ -796,6 +1003,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
isAudioCombining,
|
isAudioCombining,
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
|
highlightWordIndex,
|
||||||
|
clearWordHighlights,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
|
|
@ -813,6 +1022,8 @@ export function EPUBProvider({ children }: { children: ReactNode }) {
|
||||||
isAudioCombining,
|
isAudioCombining,
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
|
highlightWordIndex,
|
||||||
|
clearWordHighlights,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,15 +31,26 @@ import { getPdfDocument } from '@/lib/dexie';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { processTextToSentences } from '@/lib/nlp';
|
import { processTextToSentences } from '@/lib/nlp';
|
||||||
import { withRetry } from '@/utils/audio';
|
import { withRetry, getAudiobookStatus, generateTTS, createAudiobookChapter } from '@/lib/client';
|
||||||
import {
|
import {
|
||||||
extractTextFromPDF,
|
extractTextFromPDF,
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
handleTextClick,
|
clearWordHighlights,
|
||||||
|
highlightWordIndex,
|
||||||
} from '@/lib/pdf';
|
} 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
|
* Interface defining all available methods and properties in the PDF context
|
||||||
|
|
@ -59,16 +70,15 @@ interface PDFContextType {
|
||||||
onDocumentLoadSuccess: (pdf: PDFDocumentProxy) => void;
|
onDocumentLoadSuccess: (pdf: PDFDocumentProxy) => void;
|
||||||
highlightPattern: (text: string, pattern: string, containerRef: RefObject<HTMLDivElement>) => void;
|
highlightPattern: (text: string, pattern: string, containerRef: RefObject<HTMLDivElement>) => void;
|
||||||
clearHighlights: () => void;
|
clearHighlights: () => void;
|
||||||
handleTextClick: (
|
clearWordHighlights: () => void;
|
||||||
event: MouseEvent,
|
highlightWordIndex: (
|
||||||
pdfText: string,
|
alignment: TTSSentenceAlignment | undefined,
|
||||||
containerRef: RefObject<HTMLDivElement>,
|
wordIndex: number | null | undefined,
|
||||||
stopAndPlayFromIndex: (index: number) => void,
|
sentence: string | null | undefined,
|
||||||
isProcessing: boolean,
|
containerRef: RefObject<HTMLDivElement>
|
||||||
enableHighlight?: boolean
|
|
||||||
) => void;
|
) => 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>;
|
createFullAudioBook: (onProgress: (progress: number) => void, signal?: AbortSignal, onChapterComplete?: (chapter: TTSAudiobookChapter) => void, bookId?: string, format?: TTSAudiobookFormat) => 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' }>;
|
regenerateChapter: (chapterIndex: number, bookId: string, format: TTSAudiobookFormat, signal: AbortSignal) => Promise<TTSAudiobookChapter>;
|
||||||
isAudioCombining: boolean;
|
isAudioCombining: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -252,9 +262,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const createFullAudioBook = useCallback(async (
|
const createFullAudioBook = useCallback(async (
|
||||||
onProgress: (progress: number) => void,
|
onProgress: (progress: number) => void,
|
||||||
signal?: AbortSignal,
|
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,
|
providedBookId?: string,
|
||||||
format: 'mp3' | 'm4b' = 'mp3'
|
format: TTSAudiobookFormat = 'mp3'
|
||||||
): Promise<string> => {
|
): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
if (!pdfDocument) {
|
if (!pdfDocument) {
|
||||||
|
|
@ -294,17 +304,14 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const existingIndices = new Set<number>();
|
const existingIndices = new Set<number>();
|
||||||
if (bookId) {
|
if (bookId) {
|
||||||
try {
|
try {
|
||||||
const existingResponse = await fetch(`/api/audio/convert/chapters?bookId=${bookId}`);
|
const existingData = await getAudiobookStatus(bookId);
|
||||||
if (existingResponse.ok) {
|
if (existingData.chapters && existingData.chapters.length > 0) {
|
||||||
const existingData = await existingResponse.json();
|
for (const ch of existingData.chapters) {
|
||||||
if (existingData.chapters && existingData.chapters.length > 0) {
|
existingIndices.add(ch.index);
|
||||||
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})`);
|
|
||||||
}
|
}
|
||||||
|
let nextMissing = 0;
|
||||||
|
while (existingIndices.has(nextMissing)) nextMissing++;
|
||||||
|
console.log(`Resuming; next missing page index is ${nextMissing} (page ${nextMissing + 1})`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking existing chapters:', error);
|
console.error('Error checking existing chapters:', error);
|
||||||
|
|
@ -362,22 +369,7 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
throw new DOMException('Aborted', 'AbortError');
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
return await generateTTS(reqBody, reqHeaders, signal);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
retryOptions
|
retryOptions
|
||||||
);
|
);
|
||||||
|
|
@ -394,45 +386,21 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to server for conversion and storage
|
// Send to server for conversion and storage
|
||||||
const convertResponse = await fetch('/api/audio/convert', {
|
const chapter = await createAudiobookChapter({
|
||||||
method: 'POST',
|
chapterTitle,
|
||||||
headers: {
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
'Content-Type': 'application/json',
|
bookId,
|
||||||
},
|
format,
|
||||||
body: JSON.stringify({
|
chapterIndex: i
|
||||||
chapterTitle,
|
}, signal);
|
||||||
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();
|
|
||||||
|
|
||||||
if (!bookId) {
|
if (!bookId) {
|
||||||
bookId = returnedBookId;
|
bookId = chapter.bookId!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify about completed chapter
|
// Notify about completed chapter
|
||||||
if (onChapterComplete) {
|
if (onChapterComplete) {
|
||||||
onChapterComplete({
|
onChapterComplete(chapter);
|
||||||
index: chapterIndex,
|
|
||||||
title: chapterTitle,
|
|
||||||
duration,
|
|
||||||
status: 'completed',
|
|
||||||
bookId,
|
|
||||||
format
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
processedLength += text.length;
|
processedLength += text.length;
|
||||||
|
|
@ -478,9 +446,9 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
const regenerateChapter = useCallback(async (
|
const regenerateChapter = useCallback(async (
|
||||||
chapterIndex: number,
|
chapterIndex: number,
|
||||||
bookId: string,
|
bookId: string,
|
||||||
format: 'mp3' | 'm4b',
|
format: TTSAudiobookFormat,
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<{ index: number; title: string; duration?: number; status: 'pending' | 'generating' | 'completed' | 'error'; bookId?: string; format?: 'mp3' | 'm4b' }> => {
|
): Promise<TTSAudiobookChapter> => {
|
||||||
try {
|
try {
|
||||||
if (!pdfDocument) {
|
if (!pdfDocument) {
|
||||||
throw new Error('No PDF document loaded');
|
throw new Error('No PDF document loaded');
|
||||||
|
|
@ -551,28 +519,13 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
backoffFactor: 2
|
backoffFactor: 2
|
||||||
};
|
};
|
||||||
|
|
||||||
const audioBuffer = await withRetry(
|
const audioBuffer: TTSAudioBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
throw new DOMException('Aborted', 'AbortError');
|
throw new DOMException('Aborted', 'AbortError');
|
||||||
}
|
}
|
||||||
|
|
||||||
const ttsResponse = await fetch('/api/tts', {
|
return await generateTTS(reqBody, reqHeaders, signal);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
retryOptions
|
retryOptions
|
||||||
);
|
);
|
||||||
|
|
@ -582,39 +535,15 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to server for conversion and storage
|
// Send to server for conversion and storage
|
||||||
const convertResponse = await fetch('/api/audio/convert', {
|
const chapter = await createAudiobookChapter({
|
||||||
method: 'POST',
|
chapterTitle,
|
||||||
headers: {
|
buffer: Array.from(new Uint8Array(audioBuffer)),
|
||||||
'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',
|
|
||||||
bookId,
|
bookId,
|
||||||
format
|
format,
|
||||||
};
|
chapterIndex
|
||||||
|
}, signal);
|
||||||
|
|
||||||
|
return chapter;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
if (error instanceof Error && (error.name === 'AbortError' || error.message.includes('cancelled'))) {
|
||||||
|
|
@ -655,7 +584,8 @@ export function PDFProvider({ children }: { children: ReactNode }) {
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
highlightPattern,
|
highlightPattern,
|
||||||
clearHighlights,
|
clearHighlights,
|
||||||
handleTextClick,
|
clearWordHighlights,
|
||||||
|
highlightWordIndex,
|
||||||
pdfDocument,
|
pdfDocument,
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
regenerateChapter,
|
regenerateChapter,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ import { useMediaSession } from '@/hooks/audio/useMediaSession';
|
||||||
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
import { useAudioContext } from '@/hooks/audio/useAudioContext';
|
||||||
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
|
import { getLastDocumentLocation, setLastDocumentLocation } from '@/lib/dexie';
|
||||||
import { useBackgroundState } from '@/hooks/audio/useBackgroundState';
|
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 { preprocessSentenceForAudio, processTextToSentences } from '@/lib/nlp';
|
||||||
import { isKokoroModel } from '@/utils/voice';
|
import { isKokoroModel } from '@/utils/voice';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -44,10 +44,14 @@ import type {
|
||||||
TTSSmartMergeResult,
|
TTSSmartMergeResult,
|
||||||
TTSPageTurnEstimate,
|
TTSPageTurnEstimate,
|
||||||
TTSPlaybackState,
|
TTSPlaybackState,
|
||||||
|
TTSSentenceAlignment,
|
||||||
|
TTSAudioBuffer,
|
||||||
|
} from '@/types/tts';
|
||||||
|
import type {
|
||||||
TTSRequestPayload,
|
TTSRequestPayload,
|
||||||
TTSRequestHeaders,
|
TTSRequestHeaders,
|
||||||
TTSRetryOptions,
|
TTSRetryOptions,
|
||||||
} from '@/types/tts';
|
} from '@/types/client';
|
||||||
|
|
||||||
// Media globals
|
// Media globals
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -63,6 +67,10 @@ interface TTSContextType extends TTSPlaybackState {
|
||||||
// Voice settings
|
// Voice settings
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
|
|
||||||
|
// Alignment metadata for the current sentence
|
||||||
|
currentSentenceAlignment?: TTSSentenceAlignment;
|
||||||
|
currentWordIndex?: number | null;
|
||||||
|
|
||||||
// Control functions
|
// Control functions
|
||||||
togglePlay: () => void;
|
togglePlay: () => void;
|
||||||
skipForward: () => 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
|
// Create the context
|
||||||
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
const TTSContext = createContext<TTSContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
|
@ -264,6 +288,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
updateConfigKey,
|
updateConfigKey,
|
||||||
skipBlank,
|
skipBlank,
|
||||||
smartSentenceSplitting,
|
smartSentenceSplitting,
|
||||||
|
pdfHighlightEnabled,
|
||||||
|
pdfWordHighlightEnabled,
|
||||||
|
epubHighlightEnabled,
|
||||||
|
epubWordHighlightEnabled,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
|
||||||
// Audio and voice management hooks
|
// Audio and voice management hooks
|
||||||
|
|
@ -331,6 +359,19 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const epubContinuationRef = useRef<string | null>(null);
|
const epubContinuationRef = useRef<string | null>(null);
|
||||||
const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
|
const pageTurnEstimateRef = useRef<TTSPageTurnEstimate | null>(null);
|
||||||
const pageTurnTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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
|
* Processes text into sentences using the shared NLP utility
|
||||||
|
|
@ -370,6 +411,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
clearTimeout(pageTurnTimeoutRef.current);
|
clearTimeout(pageTurnTimeoutRef.current);
|
||||||
pageTurnTimeoutRef.current = null;
|
pageTurnTimeoutRef.current = null;
|
||||||
}
|
}
|
||||||
|
setCurrentWordIndex(null);
|
||||||
}, [activeHowl]);
|
}, [activeHowl]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -558,6 +600,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setCurrentIndex(0);
|
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
|
// Compute auto page-turn estimate for PDFs when we have a continuation
|
||||||
if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) {
|
if (smartSentenceSplitting && !isEPUB && continuationCarried && normalizedOptions.nextLocation !== undefined) {
|
||||||
const continuationNormalized = preprocessSentenceForAudio(continuationCarried);
|
const continuationNormalized = preprocessSentenceForAudio(continuationCarried);
|
||||||
|
|
@ -703,13 +750,71 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
* Generates and plays audio for the current sentence
|
* Generates and plays audio for the current sentence
|
||||||
*
|
*
|
||||||
* @param {string} sentence - The sentence to generate audio for
|
* @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
|
// Check if the audio is already cached
|
||||||
const cachedAudio = audioCache.get(sentence);
|
const cachedAudio = audioCache.get(audioCacheKey);
|
||||||
if (cachedAudio) {
|
if (cachedAudio) {
|
||||||
console.log('Using cached audio for sentence:', sentence.substring(0, 20));
|
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;
|
return cachedAudio;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -746,19 +851,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
|
|
||||||
const arrayBuffer = await withRetry(
|
const arrayBuffer = await withRetry(
|
||||||
async () => {
|
async () => {
|
||||||
|
return await generateTTS(reqBody, reqHeaders, controller.signal);
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
retryOptions
|
retryOptions
|
||||||
);
|
);
|
||||||
|
|
@ -767,7 +860,10 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
activeAbortControllers.current.delete(controller);
|
activeAbortControllers.current.delete(controller);
|
||||||
|
|
||||||
// Cache the array buffer
|
// 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;
|
return arrayBuffer;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -788,7 +884,21 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
});
|
});
|
||||||
throw error;
|
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
|
* Processes and plays the current sentence
|
||||||
|
|
@ -1004,11 +1114,28 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
|
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
|
||||||
|
|
||||||
const playAudio = useCallback(async () => {
|
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) {
|
if (howl) {
|
||||||
howl.play();
|
howl.play();
|
||||||
}
|
}
|
||||||
}, [sentences, currentIndex, playSentenceWithHowl]);
|
}, [sentences, currentIndex, playSentenceWithHowl, voice, speed, configTTSProvider, ttsModel]);
|
||||||
|
|
||||||
// Place useBackgroundState after playAudio is defined
|
// Place useBackgroundState after playAudio is defined
|
||||||
const isBackgrounded = useBackgroundState({
|
const isBackgrounded = useBackgroundState({
|
||||||
|
|
@ -1017,22 +1144,73 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
playAudio,
|
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
|
* Preloads the next sentence's audio
|
||||||
*/
|
*/
|
||||||
const preloadNextAudio = useCallback(async () => {
|
const preloadNextAudio = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const nextSentence = sentences[currentIndex + 1];
|
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
|
// Start preloading but don't wait for it to complete
|
||||||
processSentence(nextSentence, true).catch(error => {
|
processSentence(nextSentence, true).catch(error => {
|
||||||
console.error('Error preloading next sentence:', error);
|
console.error('Error preloading next sentence:', error);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initiating preload:', error);
|
console.error('Error initiating preload:', error);
|
||||||
}
|
}
|
||||||
}, [currentIndex, sentences, audioCache, processSentence]);
|
}, [currentIndex, sentences, audioCache, processSentence, voice, speed, configTTSProvider, ttsModel]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main Playback Driver
|
* Main Playback Driver
|
||||||
|
|
@ -1085,6 +1263,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setCurrDocPages(undefined);
|
setCurrDocPages(undefined);
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
setIsEPUB(false);
|
setIsEPUB(false);
|
||||||
|
sentenceAlignmentCacheRef.current.clear();
|
||||||
|
setCurrentSentenceAlignment(undefined);
|
||||||
|
setCurrentWordIndex(null);
|
||||||
}, [abortAudio]);
|
}, [abortAudio]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1118,9 +1299,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
abortAudio(true); // Clear pending requests since speed changed
|
abortAudio(true); // Clear pending requests since speed changed
|
||||||
setActiveHowl(null);
|
setActiveHowl(null);
|
||||||
|
|
||||||
// Update speed, clear cache, and config
|
// Update speed and config
|
||||||
setSpeed(newSpeed);
|
setSpeed(newSpeed);
|
||||||
audioCache.clear();
|
|
||||||
|
|
||||||
// Update config after state changes
|
// Update config after state changes
|
||||||
updateConfigKey('voiceSpeed', newSpeed).then(() => {
|
updateConfigKey('voiceSpeed', newSpeed).then(() => {
|
||||||
|
|
@ -1130,7 +1310,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [abortAudio, updateConfigKey, audioCache, isPlaying]);
|
}, [abortAudio, updateConfigKey, isPlaying]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the voice and restarts the playback
|
* 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
|
abortAudio(true); // Clear pending requests since voice changed
|
||||||
setActiveHowl(null);
|
setActiveHowl(null);
|
||||||
|
|
||||||
// Update voice, clear cache, and config
|
// Update voice and config
|
||||||
setVoice(newVoice);
|
setVoice(newVoice);
|
||||||
audioCache.clear();
|
|
||||||
|
|
||||||
// Update config after state changes
|
// Update config after state changes
|
||||||
updateConfigKey('voice', newVoice).then(() => {
|
updateConfigKey('voice', newVoice).then(() => {
|
||||||
|
|
@ -1163,7 +1342,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [abortAudio, updateConfigKey, audioCache, isPlaying]);
|
}, [abortAudio, updateConfigKey, isPlaying]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the audio player speed and restarts the playback
|
* Sets the audio player speed and restarts the playback
|
||||||
|
|
@ -1205,6 +1384,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isBackgrounded,
|
isBackgrounded,
|
||||||
currentSentence: sentences[currentIndex] || '',
|
currentSentence: sentences[currentIndex] || '',
|
||||||
|
currentSentenceAlignment,
|
||||||
|
currentWordIndex,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocPageNumber,
|
currDocPageNumber,
|
||||||
currDocPages,
|
currDocPages,
|
||||||
|
|
@ -1248,7 +1429,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
skipToLocation,
|
skipToLocation,
|
||||||
registerLocationChangeHandler,
|
registerLocationChangeHandler,
|
||||||
registerVisualPageChangeHandler,
|
registerVisualPageChangeHandler,
|
||||||
setIsEPUB
|
setIsEPUB,
|
||||||
|
currentSentenceAlignment,
|
||||||
|
currentWordIndex
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Use media session hook
|
// Use media session hook
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
|
import type { TTSAudioBuffer } from '@/types/tts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom hook for managing audio cache using LRU strategy
|
* Custom hook for managing audio cache using LRU strategy
|
||||||
|
|
@ -9,11 +10,11 @@ import { LRUCache } from 'lru-cache';
|
||||||
* @returns Object containing cache methods
|
* @returns Object containing cache methods
|
||||||
*/
|
*/
|
||||||
export function useAudioCache(maxSize = 50) {
|
export function useAudioCache(maxSize = 50) {
|
||||||
const cacheRef = useRef(new LRUCache<string, ArrayBuffer>({ max: maxSize }));
|
const cacheRef = useRef(new LRUCache<string, TTSAudioBuffer>({ max: maxSize }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get: (key: string) => cacheRef.current.get(key),
|
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),
|
delete: (key: string) => cacheRef.current.delete(key),
|
||||||
has: (key: string) => cacheRef.current.has(key),
|
has: (key: string) => cacheRef.current.has(key),
|
||||||
clear: () => cacheRef.current.clear(),
|
clear: () => cacheRef.current.clear(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
import { getVoices } from '@/lib/client';
|
||||||
|
|
||||||
const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
|
||||||
|
|
||||||
|
|
@ -23,18 +24,14 @@ export function useVoiceManagement(
|
||||||
const fetchVoices = useCallback(async () => {
|
const fetchVoices = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
console.log('Fetching voices...');
|
console.log('Fetching voices...');
|
||||||
const response = await fetch('/api/tts/voices', {
|
const data = await getVoices({
|
||||||
headers: {
|
'x-openai-key': apiKey || '',
|
||||||
'x-openai-key': apiKey || '',
|
'x-openai-base-url': baseUrl || '',
|
||||||
'x-openai-base-url': baseUrl || '',
|
'x-tts-provider': ttsProvider || 'openai',
|
||||||
'x-tts-provider': ttsProvider || 'openai',
|
'x-tts-model': ttsModel || 'tts-1',
|
||||||
'x-tts-model': ttsModel || 'tts-1',
|
'Content-Type': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch voices');
|
|
||||||
const data = await response.json();
|
|
||||||
setAvailableVoices(data.voices || DEFAULT_VOICES);
|
setAvailableVoices(data.voices || DEFAULT_VOICES);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching voices:', error);
|
console.error('Error fetching voices:', error);
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,41 @@ import { useCallback, useEffect } from 'react';
|
||||||
import { Rendition } from 'epubjs';
|
import { Rendition } from 'epubjs';
|
||||||
import { ReactReaderStyle, IReactReaderStyle } from 'react-reader';
|
import { ReactReaderStyle, IReactReaderStyle } from 'react-reader';
|
||||||
|
|
||||||
export const getThemeStyles = (): IReactReaderStyle => {
|
// Returns ReactReader styles, with:
|
||||||
const baseStyle = {
|
// - default look when epubTheme === false (except hiding built-in arrows)
|
||||||
...ReactReaderStyle,
|
// - themed colors + layout tweaks when epubTheme === true
|
||||||
readerArea: {
|
export const getThemeStyles = (epubTheme: boolean): IReactReaderStyle => {
|
||||||
...ReactReaderStyle.readerArea,
|
const baseStyle = ReactReaderStyle;
|
||||||
transition: undefined,
|
|
||||||
}
|
// 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 = {
|
const colors = {
|
||||||
background: getComputedStyle(document.documentElement).getPropertyValue('--background'),
|
background: getComputedStyle(document.documentElement).getPropertyValue('--background'),
|
||||||
|
|
@ -21,6 +48,25 @@ export const getThemeStyles = (): IReactReaderStyle => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseStyle,
|
...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: {
|
arrow: {
|
||||||
...baseStyle.arrow,
|
...baseStyle.arrow,
|
||||||
color: colors.foreground,
|
color: colors.foreground,
|
||||||
|
|
@ -54,6 +100,9 @@ export const getThemeStyles = (): IReactReaderStyle => {
|
||||||
tocButton: {
|
tocButton: {
|
||||||
...baseStyle.tocButton,
|
...baseStyle.tocButton,
|
||||||
color: colors.muted,
|
color: colors.muted,
|
||||||
|
// Ensure the TOC toggle sits above the swipe wrapper
|
||||||
|
// and text iframe, avoiding z-index conflicts.
|
||||||
|
zIndex: 300,
|
||||||
},
|
},
|
||||||
tocAreaButton: {
|
tocAreaButton: {
|
||||||
...baseStyle.tocAreaButton,
|
...baseStyle.tocAreaButton,
|
||||||
|
|
@ -117,4 +166,4 @@ export const useEPUBTheme = (epubTheme: boolean, rendition: Rendition | undefine
|
||||||
}, [epubTheme, rendition, updateTheme]);
|
}, [epubTheme, rendition, updateTheme]);
|
||||||
|
|
||||||
return { 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,
|
savedVoices,
|
||||||
pdfHighlightEnabled:
|
pdfHighlightEnabled:
|
||||||
raw.pdfHighlightEnabled === 'false' ? false : APP_CONFIG_DEFAULTS.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',
|
firstVisit: raw.firstVisit === 'true',
|
||||||
documentListState,
|
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 { TextItem } from 'pdfjs-dist/types/src/display/api';
|
||||||
import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist';
|
import { type PDFDocumentProxy, TextLayer } from 'pdfjs-dist';
|
||||||
import "core-js/proposals/promise-with-resolvers";
|
import "core-js/proposals/promise-with-resolvers";
|
||||||
import { processTextToSentences } from '@/lib/nlp';
|
import type { TTSSentenceAlignment } from '@/types/tts';
|
||||||
import { CmpStr } from 'cmpstr';
|
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
|
// Worker coordination for offloading highlight token matching
|
||||||
interface HighlightTokenMatchRequest {
|
interface HighlightTokenMatchRequest {
|
||||||
|
|
@ -141,12 +141,25 @@ try {
|
||||||
console.error('Error patching TextLayer.render:', e);
|
console.error('Error patching TextLayer.render:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TextMatch {
|
type PDFToken = {
|
||||||
elements: HTMLElement[];
|
spanIndex: number;
|
||||||
rating: number;
|
textNode: Text;
|
||||||
text: string;
|
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
|
// Text Processing functions
|
||||||
export async function extractTextFromPDF(
|
export async function extractTextFromPDF(
|
||||||
|
|
@ -279,50 +292,23 @@ export function clearHighlights() {
|
||||||
element.parentElement.removeChild(element);
|
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(
|
export function clearWordHighlights() {
|
||||||
elements: Array<{ element: HTMLElement; text: string }>,
|
const wordOverlays = document.querySelectorAll('.pdf-word-highlight-overlay');
|
||||||
targetText: string,
|
wordOverlays.forEach((node) => {
|
||||||
maxCombinedLength: number
|
const element = node as HTMLElement;
|
||||||
): TextMatch {
|
if (element.parentElement) {
|
||||||
let bestMatch = {
|
element.parentElement.removeChild(element);
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
return bestMatch;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function highlightPattern(
|
export function highlightPattern(
|
||||||
|
|
@ -338,22 +324,18 @@ export function highlightPattern(
|
||||||
|
|
||||||
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
|
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
|
||||||
if (!cleanPattern) return;
|
if (!cleanPattern) return;
|
||||||
|
lastSentencePattern = cleanPattern;
|
||||||
|
lastSentenceWordToTokenMap = null;
|
||||||
|
lastSentenceTokenWindow = null;
|
||||||
|
|
||||||
const spanNodes = Array.from(
|
const spanNodes = Array.from(
|
||||||
container.querySelectorAll('.react-pdf__Page__textContent span')
|
container.querySelectorAll('.react-pdf__Page__textContent span')
|
||||||
) as HTMLElement[];
|
) as HTMLElement[];
|
||||||
|
|
||||||
if (!spanNodes.length) return;
|
if (!spanNodes.length) return;
|
||||||
|
lastSpanNodes = spanNodes;
|
||||||
|
|
||||||
type Token = {
|
const tokens: PDFToken[] = [];
|
||||||
spanIndex: number;
|
|
||||||
textNode: Text;
|
|
||||||
text: string;
|
|
||||||
startOffset: number;
|
|
||||||
endOffset: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const tokens: Token[] = [];
|
|
||||||
|
|
||||||
spanNodes.forEach((span, spanIndex) => {
|
spanNodes.forEach((span, spanIndex) => {
|
||||||
const node = span.firstChild;
|
const node = span.firstChild;
|
||||||
|
|
@ -377,6 +359,7 @@ export function highlightPattern(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!tokens.length) return;
|
if (!tokens.length) return;
|
||||||
|
lastTokens = tokens;
|
||||||
|
|
||||||
const patternLen = cleanPattern.length;
|
const patternLen = cleanPattern.length;
|
||||||
|
|
||||||
|
|
@ -415,6 +398,11 @@ export function highlightPattern(
|
||||||
bestLengthDiff < patternLen * 0.3 ? 0.3 : 0.5;
|
bestLengthDiff < patternLen * 0.3 ? 0.3 : 0.5;
|
||||||
|
|
||||||
if (hasTokenMatch && bestRating >= similarityThreshold) {
|
if (hasTokenMatch && bestRating >= similarityThreshold) {
|
||||||
|
lastSentenceTokenWindow = {
|
||||||
|
start: bestStart,
|
||||||
|
end: bestEnd,
|
||||||
|
};
|
||||||
|
|
||||||
const rangesBySpan = new Map<
|
const rangesBySpan = new Map<
|
||||||
number,
|
number,
|
||||||
{ startOffset: number; endOffset: 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;
|
if (!highlightRanges.length) return;
|
||||||
|
|
||||||
// Create overlay rectangles for each range, relative to its page text layer
|
// Create overlay rectangles for each range, relative to its page text layer
|
||||||
|
|
@ -577,7 +506,7 @@ export function highlightPattern(
|
||||||
runHighlightTokenMatch(cleanPattern, tokenTexts)
|
runHighlightTokenMatch(cleanPattern, tokenTexts)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (!result || result.bestStart === -1) {
|
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);
|
applyHighlightFromTokens(null);
|
||||||
} else {
|
} else {
|
||||||
applyHighlightFromTokens({
|
applyHighlightFromTokens({
|
||||||
|
|
@ -590,95 +519,199 @@ export function highlightPattern(
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(
|
console.error(
|
||||||
'Error in PDF highlight worker, falling back to span-based matching:',
|
'Error in PDF highlight worker; no highlights applied:',
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
applyHighlightFromTokens(null);
|
applyHighlightFromTokens(null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text Click Handler
|
export function highlightWordIndex(
|
||||||
export function handleTextClick(
|
alignment: TTSSentenceAlignment | undefined,
|
||||||
event: MouseEvent,
|
wordIndex: number | null | undefined,
|
||||||
pdfText: string,
|
sentence: string | null | undefined,
|
||||||
containerRef: React.RefObject<HTMLDivElement>,
|
containerRef: React.RefObject<HTMLDivElement>
|
||||||
stopAndPlayFromIndex: (index: number) => void,
|
|
||||||
isProcessing: boolean,
|
|
||||||
enableHighlight = true
|
|
||||||
) {
|
) {
|
||||||
if (isProcessing) return;
|
clearWordHighlights();
|
||||||
|
|
||||||
const target = event.target as HTMLElement;
|
if (!alignment) return;
|
||||||
if (!target.matches('.react-pdf__Page__textContent span')) return;
|
if (wordIndex === null || wordIndex === undefined || wordIndex < 0) {
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allText = Array.from(parentElement.querySelectorAll('span')).map((node) => ({
|
const words = alignment.words || [];
|
||||||
element: node as HTMLElement,
|
if (!words.length || wordIndex >= words.length) return;
|
||||||
text: (node.textContent || '').trim(),
|
|
||||||
})).filter((node) => node.text.length > 0);
|
|
||||||
|
|
||||||
const bestMatch = findBestTextMatch(allText, cleanContext, cleanContext.length * 2);
|
const container = containerRef.current;
|
||||||
const similarityThreshold = bestMatch.lengthDiff < cleanContext.length * 0.3 ? 0.3 : 0.5;
|
if (!container) return;
|
||||||
|
if (!lastSentenceTokenWindow) return;
|
||||||
|
if (!lastTokens.length || !lastSpanNodes.length) return;
|
||||||
|
|
||||||
if (bestMatch.rating >= similarityThreshold) {
|
const cleanSentence =
|
||||||
const matchText = bestMatch.text;
|
sentence && sentence.trim()
|
||||||
// Use the same sentence processing logic as TTSContext for consistency
|
? sentence.trim().replace(/\s+/g, ' ')
|
||||||
const sentences = processTextToSentences(pdfText);
|
: null;
|
||||||
console.log("sentences inside handleTextClick: %d", sentences.length)
|
if (!cleanSentence || !lastSentencePattern) return;
|
||||||
let bestSentenceMatch = { sentence: '', rating: 0 };
|
if (cleanSentence !== lastSentencePattern) return;
|
||||||
|
|
||||||
for (const sentence of sentences) {
|
const start = lastSentenceTokenWindow.start;
|
||||||
const rating = cmp.compare(matchText, sentence);
|
const end = lastSentenceTokenWindow.end;
|
||||||
if (rating > bestSentenceMatch.rating) {
|
if (end < start) return;
|
||||||
bestSentenceMatch = { sentence, rating };
|
|
||||||
}
|
// 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 ttsFiltered: { wordIndex: number; norm: string }[] = [];
|
||||||
const sentenceIndex = sentences.findIndex((sentence) => sentence === bestSentenceMatch.sentence);
|
for (let i = 0; i < words.length; i++) {
|
||||||
if (sentenceIndex !== -1) {
|
const norm = normalizeWordForMatch(words[i].text);
|
||||||
stopAndPlayFromIndex(sentenceIndex);
|
if (!norm) continue;
|
||||||
if (enableHighlight) {
|
ttsFiltered.push({ wordIndex: i, norm });
|
||||||
highlightPattern(pdfText, bestSentenceMatch.sentence, containerRef);
|
}
|
||||||
|
|
||||||
|
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';
|
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 ViewType = 'single' | 'dual' | 'scroll';
|
||||||
|
|
||||||
export type SavedVoices = Record<string, string>;
|
export type SavedVoices = Record<string, string>;
|
||||||
|
|
@ -23,7 +25,9 @@ export interface AppConfigValues {
|
||||||
savedVoices: SavedVoices;
|
savedVoices: SavedVoices;
|
||||||
smartSentenceSplitting: boolean;
|
smartSentenceSplitting: boolean;
|
||||||
pdfHighlightEnabled: boolean;
|
pdfHighlightEnabled: boolean;
|
||||||
|
pdfWordHighlightEnabled: boolean;
|
||||||
epubHighlightEnabled: boolean;
|
epubHighlightEnabled: boolean;
|
||||||
|
epubWordHighlightEnabled: boolean;
|
||||||
firstVisit: boolean;
|
firstVisit: boolean;
|
||||||
documentListState: DocumentListState;
|
documentListState: DocumentListState;
|
||||||
}
|
}
|
||||||
|
|
@ -41,13 +45,15 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
||||||
footerMargin: 0,
|
footerMargin: 0,
|
||||||
leftMargin: 0,
|
leftMargin: 0,
|
||||||
rightMargin: 0,
|
rightMargin: 0,
|
||||||
ttsProvider: 'custom-openai',
|
ttsProvider: isDev ? 'custom-openai' : 'deepinfra',
|
||||||
ttsModel: 'kokoro',
|
ttsModel: isDev ? 'kokoro' : 'hexgrad/Kokoro-82M',
|
||||||
ttsInstructions: '',
|
ttsInstructions: '',
|
||||||
savedVoices: {},
|
savedVoices: {},
|
||||||
smartSentenceSplitting: true,
|
smartSentenceSplitting: true,
|
||||||
pdfHighlightEnabled: true,
|
pdfHighlightEnabled: true,
|
||||||
|
pdfWordHighlightEnabled: isDev,
|
||||||
epubHighlightEnabled: true,
|
epubHighlightEnabled: true,
|
||||||
|
epubWordHighlightEnabled: isDev,
|
||||||
firstVisit: false,
|
firstVisit: false,
|
||||||
documentListState: {
|
documentListState: {
|
||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
|
|
@ -55,6 +61,7 @@ export const APP_CONFIG_DEFAULTS: AppConfigValues = {
|
||||||
folders: [],
|
folders: [],
|
||||||
collapsedFolders: [],
|
collapsedFolders: [],
|
||||||
showHint: true,
|
showHint: true,
|
||||||
|
viewMode: 'grid',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,4 +63,5 @@ export interface DocumentListState {
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
collapsedFolders: string[];
|
collapsedFolders: string[];
|
||||||
showHint: boolean;
|
showHint: boolean;
|
||||||
|
viewMode?: 'list' | 'grid';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
export type TTSLocation = string | number;
|
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
|
// Standardized error codes for the TTS API
|
||||||
export type TTSErrorCode =
|
export type TTSErrorCode =
|
||||||
| 'MISSING_PARAMETERS'
|
| 'MISSING_PARAMETERS'
|
||||||
|
|
@ -15,22 +19,6 @@ export interface TTSError {
|
||||||
details?: unknown;
|
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
|
// Core playback state exposed by the TTS context
|
||||||
export interface TTSPlaybackState {
|
export interface TTSPlaybackState {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
|
|
@ -42,14 +30,6 @@ export interface TTSPlaybackState {
|
||||||
currDocPages?: number;
|
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
|
// Result of merging a continuation slice into the current text
|
||||||
export interface TTSSmartMergeResult {
|
export interface TTSSmartMergeResult {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -63,6 +43,25 @@ export interface TTSPageTurnEstimate {
|
||||||
fraction: number;
|
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
|
// Metadata for an audiobook chapter
|
||||||
export interface TTSAudiobookChapter {
|
export interface TTSAudiobookChapter {
|
||||||
index: number;
|
index: number;
|
||||||
|
|
@ -70,5 +69,5 @@ export interface TTSAudiobookChapter {
|
||||||
duration?: number;
|
duration?: number;
|
||||||
status: 'pending' | 'generating' | 'completed' | 'error';
|
status: 'pending' | 'generating' | 'completed' | 'error';
|
||||||
bookId?: string;
|
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],
|
plugins: [typography],
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,7 @@ API_KEY=api_key_here_if_needed
|
||||||
|
|
||||||
# OpenAI API Base URL (default)
|
# OpenAI API Base URL (default)
|
||||||
# To use a local TTS model server, I suggest using https://github.com/remsky/Kokoro-FastAPI
|
# 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);
|
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 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();
|
expect(res.ok()).toBeTruthy();
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
expect(json).toHaveProperty('exists');
|
expect(json).toHaveProperty('exists');
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ async function getAudioDurationSeconds(filePath: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectChaptersBackendState(page: Page, bookId: 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();
|
expect(res.ok()).toBeTruthy();
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
return json;
|
return json;
|
||||||
|
|
@ -271,7 +271,7 @@ test.describe('Audiobook export', () => {
|
||||||
).toBeVisible({ timeout: 60_000 });
|
).toBeVisible({ timeout: 60_000 });
|
||||||
|
|
||||||
// Backend should report no existing chapters for this bookId
|
// 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();
|
expect(res.ok()).toBeTruthy();
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
expect(json.exists).toBe(false);
|
expect(json.exists).toBe(false);
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ export async function openSettingsDocumentsTab(page: Page) {
|
||||||
// Delete all local documents through Settings and close dialogs
|
// Delete all local documents through Settings and close dialogs
|
||||||
export async function deleteAllLocalDocuments(page: Page) {
|
export async function deleteAllLocalDocuments(page: Page) {
|
||||||
await openSettingsDocumentsTab(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' });
|
const heading = page.getByRole('heading', { name: 'Delete Local Documents' });
|
||||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ test.describe('Document Upload Tests', () => {
|
||||||
test('displays an EPUB document', async ({ page }) => {
|
test('displays an EPUB document', async ({ page }) => {
|
||||||
await uploadAndDisplay(page, 'sample.epub');
|
await uploadAndDisplay(page, 'sample.epub');
|
||||||
await expectViewerForFile(page, 'sample.epub');
|
await expectViewerForFile(page, 'sample.epub');
|
||||||
// Keep navigation button assertions
|
// Navigation controls should be exposed via accessible labels
|
||||||
await expect(page.getByRole('button', { name: '‹' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Previous section' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: '›' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Next section' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('displays a DOCX document as PDF after conversion', async ({ page }) => {
|
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
|
// Also ensure no link with that filename exists
|
||||||
await expect(page.getByRole('link', { name: /unsupported\.xyz/i })).toHaveCount(0);
|
await expect(page.getByRole('link', { name: /unsupported\.xyz/i })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue