diff --git a/Dockerfile b/Dockerfile index e1a3105..54a144b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,11 @@ RUN npm install # Copy project files COPY . . -# Build the Next.js application +# Install TypeScript globally for builds +RUN npm install -g typescript + +# Build the Next.js application with extra memory allocation to handle larger builds +ENV NODE_OPTIONS="--max-old-space-size=4096" RUN npm run build # Expose the port the app runs on diff --git a/README.md b/README.md index cf00d15..bfbafad 100644 --- a/README.md +++ b/README.md @@ -1,179 +1,85 @@ -[![GitHub Stars](https://img.shields.io/github/stars/richardr1126/OpenReader-WebUI)](../../stargazers) -[![GitHub Forks](https://img.shields.io/github/forks/richardr1126/OpenReader-WebUI)](../../network/members) -[![GitHub Watchers](https://img.shields.io/github/watchers/richardr1126/OpenReader-WebUI)](../../watchers) -[![GitHub Issues](https://img.shields.io/github/issues/richardr1126/OpenReader-WebUI)](../../issues) -[![GitHub Last Commit](https://img.shields.io/github/last-commit/richardr1126/OpenReader-WebUI)](../../commits) -[![GitHub Release](https://img.shields.io/github/v/release/richardr1126/OpenReader-WebUI)](../../releases) +# OpenReader WebUI -[![Discussions](https://img.shields.io/badge/Discussions-Ask%20a%20Question-blue)](../../discussions) -[![Bluesky](https://img.shields.io/badge/Bluesky-Chat%20with%20me-blue)](https://bsky.app/profile/richardr.dev) +A web-based document reader with AI-powered text-to-speech functionality, supporting both PDF and EPUB formats. +## Features -# OpenReader WebUI 📄🔊 +- 📱 Responsive design for all devices +- 📚 Support for both PDF and EPUB documents +- 🔊 AI-powered text-to-speech with multiple provider options +- 📁 Document organization with folders +- 🌓 Light and dark themes +- 🔄 Adjustable reading speed -OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering a TTS read along experience with narration for both PDF and EPUB documents. It can use any OpenAI compatible TTS endpoint, including [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI). +## 🆕 Multiple AI Provider Support -- 🎯 **TTS API Integration**: Compatible with OpenAI text to speech API, Kokoro FastAPI TTS, or any other compatible service; enabling high-quality voice narration -- 💾 **Local-First Architecture**: Uses IndexedDB browser storage - no server uploads required -- 🛜 **Optional Server-side documents**: Manually upload documents to the next backend for all users to download -- 📖 **Read Along Experience**: Follow along with highlighted text as the TTS narrates -- 📚 **EPUB Support**: Read EPUB files with table of contents -- 📄 **PDF Support**: Read PDF files with clean text extraction -- 🎧 **Audiobook Creation**: Create and export audiobooks from PDF and ePub files with m4b format -- 📲 **Mobile Support**: Works on mobile devices, and can be added as a PWA web app -- 🎨 **Customizable Experience**: - - 🔑 Set TTS API base URL (with optional API key) - - 🏎️ Adjustable playback speed - - 📐 Customize PDF text extraction margins - - 🗣️ Multiple voice options (checks `/v1/audio/voices` endpoint) - - 🎨 Multiple app theme options - +OpenReader now supports multiple AI providers for text-to-speech: -### 🛠️ Work in progress -- [x] **Audiobook creation and download** (m4b format) -- [x] **Get PDFs on iOS working** -- [ ] **End-to-end Testing**: More playwright tests (in progress) -- [ ] **More document formats**: .txt, .docx, .md, etc. -- [ ] **Accessibility Improvements** +- **OpenAI** - High-quality TTS using GPT models +- **OpenRouter** - API gateway to various AI models +- **Ollama** - Local AI model support +- **ElevenLabs** - Premium voice synthesis with natural-sounding voices -## 🐳 Docker Quick Start +## Setup + +1. Clone the repository +2. Install dependencies: `npm install` +3. Copy `template.env` to `.env.local` and fill in your API keys +4. Start the development server: `npm run dev` + +### Provider Configuration + +Configure your preferred AI providers in `.env.local` or through the settings panel: + +#### OpenAI (Default) +``` +OPENAI_API_KEY=your_openai_key +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=tts-1 +``` + +#### OpenRouter +``` +OPENROUTER_API_KEY=your_openrouter_key +OPENROUTER_MODEL=openai/whisper +``` + +#### Ollama (Local) +``` +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_MODEL=llama3:8b +``` + +#### ElevenLabs Voice Provider +``` +ELEVENLABS_API_KEY=your_elevenlabs_key +``` + +## Voice Selection + +OpenReader provides a user interface for selecting and managing voices: + +- Choose between OpenAI or ElevenLabs voice providers +- Select from available voices for each provider +- Custom voice support with ElevenLabs (requires ElevenLabs subscription) +- Voice preferences are saved per document + +## Development + +- Built with Next.js 14 +- TypeScript for type safety +- Tailwind CSS for styling +- Headless UI for accessible components + +## Docker Deployment + +Build and run with Docker: ```bash -docker run --name openreader-webui \ - -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ - ghcr.io/richardr1126/openreader-webui:latest +docker build -t openreader-webui . +docker run -p 3003:3003 -e OPENAI_API_KEY=your_key openreader-webui ``` -(Optionally): Set the TTS `API_BASE` URL and/or `API_KEY` to be default for all devices -```bash -docker run --name openreader-webui \ - -e API_BASE=http://host.docker.internal:8880/v1 \ - -p 3003:3003 \ - -v openreader_docstore:/app/docstore \ - ghcr.io/richardr1126/openreader-webui:latest -``` - -> Requesting audio from the TTS API happens on the Next.js server not the client. So the base URL for the TTS API should be accessible and relative to the Next.js server. If it is in a Docker you may need to use `host.docker.internal` to access the host machine, instead of `localhost`. - -Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings. - -> **Note:** The `openreader_docstore` volume is used to store server-side documents. You can mount a local directory instead. Or remove it if you don't need server-side documents. - -### ⬆️ Update Docker Image -```bash -docker stop openreader-webui && docker rm openreader-webui -docker pull ghcr.io/richardr1126/openreader-webui:latest -``` - -### Adding to a Docker Compose (i.e. with open-webui or Kokoro-FastAPI) -Create or add to a `docker-compose.yml`: -```yaml -volumes: - docstore: - -services: - openreader-webui: - container_name: openreader-webui - image: ghcr.io/richardr1126/openreader-webui:latest - environment: - - API_BASE=http://host.docker.internal:8880/v1 - ports: - - "3003:3003" - volumes: - - docstore:/app/docstore - restart: unless-stopped -``` - -## [**Demo**](https://openreader.richardr.dev/) - - -https://github.com/user-attachments/assets/262b9a01-c608-4fee-893c-9461dd48c99b - -## Dev Installation - -### Prerequisites -- Node.js & npm (recommended: use [nvm](https://github.com/nvm-sh/nvm)) -- [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only) - -### Steps - -1. Clone the repository: - ```bash - git clone https://github.com/richardr1126/OpenReader-WebUI.git - cd OpenReader-WebUI - ``` - -2. Install dependencies: - ```bash - npm install - ``` - -3. Configure the environment: - ```bash - cp template.env .env - # Edit .env with your configuration settings - ``` - > Note: The base URL for the TTS API should be accessible and relative to the Next.js server - -4. Start the development server: - ```bash - npm run dev - ``` - - or build and run the production server: - ```bash - npm run build - npm start - ``` - - Visit [http://localhost:3003](http://localhost:3003) to run the app. - - > Dev server runs on port 3000 by default, while the production server runs on port 3003. - - -## 💡 Feature requests - -For feature requests or ideas you have for the project, please use the [Discussions](https://github.com/richardr1126/OpenReader-WebUI/discussions) tab. - -## 🙋‍♂️ Support and issues - -For general questions, you can reach out to me on [Bluesky](https://bsky.app/profile/richardr.dev). If you encounter issues, please open an issue on GitHub following the template (which is very simple). - -## 👥 Contributing - -Contributions are welcome! Fork the repository and submit a pull request with your changes. - -## ❤️ Acknowledgements - -- [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) for the API wrapper -- [react-pdf](https://github.com/wojtekmaj/react-pdf) -- [react-reader](https://github.com/happyr/react-reader) -- [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M) for text-to-speech - -## Docker Supported Architectures -- linux/amd64 (x86_64) -- linux/arm64 (Apple Silicon) - -## Stack - -- **Framework:** Next.js (React) -- **Containerization:** Docker -- **Storage:** IndexedDB (in browser db store) -- **PDF:** - - [react-pdf](https://github.com/wojtekmaj/react-pdf) - - [pdf.js](https://mozilla.github.io/pdf.js/) -- **EPUB:** - - [react-reader](https://github.com/happyr/react-reader) - - [epubjs](https://github.com/futurepress/epub.js/) -- **UI:** - - [Tailwind CSS](https://tailwindcss.com) - - [Headless UI](https://headlessui.com) -- **TTS:** (tested on) - - [OpenAI API](https://platform.openai.com/docs/api-reference/text-to-speech) - - [Kokoro FastAPI TTS](https://github.com/remsky/Kokoro-FastAPI/tree/v0.0.5post1-stable) -- **NLP:** [compromise](https://github.com/spencermountain/compromise) NLP library for sentence splitting - ## License -This project is licensed under the MIT License. +MIT diff --git a/package-lock.json b/package-lock.json index f26790c..25f6546 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openreader-webui", - "version": "0.2.4-patch.1", + "version": "0.2.4-patch.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openreader-webui", - "version": "0.2.4-patch.1", + "version": "0.2.4-patch.3", "dependencies": { "@headlessui/react": "^2.2.0", "@types/howler": "^2.2.12", diff --git a/src/app/api/audio/convert/route.ts b/src/app/api/audio/convert/route.ts index e57dcd2..9e2acf1 100644 --- a/src/app/api/audio/convert/route.ts +++ b/src/app/api/audio/convert/route.ts @@ -1,255 +1,102 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { spawn } from 'child_process'; -import { writeFile, readFile, mkdir, unlink, rmdir, readdir } from 'fs/promises'; -import { existsSync, createReadStream } from 'fs'; -import { join } from 'path'; -import { randomUUID } from 'crypto'; +/** + * Audio Conversion API Endpoint + * + * This endpoint handles converting text to speech when direct TTS is not available from a provider. + * It is primarily used as a fallback for providers like Ollama that don't have native TTS capabilities. + */ -interface ConversionRequest { - chapterTitle: string; - buffer: number[]; - bookId?: string; -} +import { NextRequest } from 'next/server'; +import { createVoiceProvider } from '@/providers/factory'; +import { ProviderSettings, VoiceProviderType, ProviderType } from '@/providers/types'; -async function getAudioDuration(filePath: string): Promise { - return new Promise((resolve, reject) => { - const ffprobe = spawn('ffprobe', [ - '-i', filePath, - '-show_entries', 'format=duration', - '-v', 'quiet', - '-of', 'csv=p=0' - ]); - - let output = ''; - ffprobe.stdout.on('data', (data) => { - output += data.toString(); - }); - - ffprobe.on('close', (code) => { - if (code === 0) { - const duration = parseFloat(output.trim()); - resolve(duration); - } else { - reject(new Error(`ffprobe process exited with code ${code}`)); - } - }); - - ffprobe.on('error', (err) => { - reject(err); - }); - }); -} - -async function runFFmpeg(args: string[]): Promise { - return new Promise((resolve, reject) => { - const ffmpeg = spawn('ffmpeg', args); - - ffmpeg.stderr.on('data', (data) => { - console.error(`ffmpeg stderr: ${data}`); - }); - - ffmpeg.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`FFmpeg process exited with code ${code}`)); - } - }); - - ffmpeg.on('error', (err) => { - reject(err); - }); - }); -} - -export async function POST(request: NextRequest) { +export async function POST(req: Request) { try { - // Parse the request body - const data: ConversionRequest = await request.json(); + const body = await req.json(); + const { text, voice, speed, provider, voiceProvider } = body; - // Create temp directory if it doesn't exist - const tempDir = join(process.cwd(), 'temp'); - if (!existsSync(tempDir)) { - await mkdir(tempDir); + if (!text) { + return Response.json({ error: 'Text is required' }, { status: 400 }); } - - // Generate or use existing book ID - const bookId = data.bookId || randomUUID(); - const intermediateDir = join(tempDir, `${bookId}-intermediate`); - // Create intermediate directory - if (!existsSync(intermediateDir)) { - await mkdir(intermediateDir); - } - - // Count existing files to determine chapter index - const files = await readdir(intermediateDir); - const wavFiles = files.filter(f => f.endsWith('.wav')); - const chapterIndex = wavFiles.length; - - // Write input file - const inputPath = join(intermediateDir, `${chapterIndex}-input.aac`); - const outputPath = join(intermediateDir, `${chapterIndex}.wav`); - const metadataPath = join(intermediateDir, `${chapterIndex}.meta.json`); + // Use the provided voice provider or default to OpenAI + const effectiveVoiceProvider: VoiceProviderType = voiceProvider || 'openai'; + const effectiveProvider: ProviderType = provider || 'openai'; - // Write the chapter audio to a temp file - await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer))); + // Extract API keys from headers if present + const headers = new Headers(req.headers); + const openaiKey = headers.get('x-openai-key') || ''; + const elevenLabsKey = headers.get('x-elevenlabs-key') || ''; + const openrouterKey = headers.get('x-openrouter-key') || ''; - // Convert to WAV from raw aac with consistent format - await runFFmpeg([ - '-i', inputPath, - '-f', 'wav', - '-c:a', 'copy', - '-preset', 'ultrafast', - '-threads', '0', - outputPath - ]); - - // Get the duration and save metadata - const duration = await getAudioDuration(outputPath); - await writeFile(metadataPath, JSON.stringify({ - title: data.chapterTitle, - duration, - index: chapterIndex - })); - - // Clean up input file - await unlink(inputPath).catch(console.error); - - return NextResponse.json({ - bookId, - chapterIndex, - duration - }); - - } catch (error) { - console.error('Error processing audio chapter:', error); - return NextResponse.json( - { error: 'Failed to process audio chapter' }, - { status: 500 } - ); - } -} - -export async function GET(request: NextRequest) { - try { - const bookId = request.nextUrl.searchParams.get('bookId'); - if (!bookId) { - return NextResponse.json({ error: 'Missing bookId parameter' }, { status: 400 }); - } - - const tempDir = join(process.cwd(), 'temp'); - const intermediateDir = join(tempDir, `${bookId}-intermediate`); - const outputPath = join(tempDir, `${bookId}.m4b`); - const metadataPath = join(tempDir, `${bookId}-metadata.txt`); - const listPath = join(tempDir, `${bookId}-list.txt`); - - if (!existsSync(intermediateDir)) { - return NextResponse.json({ error: 'Book not found' }, { status: 404 }); - } - - // Read all chapter metadata - const files = await readdir(intermediateDir); - const metaFiles = files.filter(f => f.endsWith('.meta.json')); - const chapters: { title: string; duration: number; index: number }[] = []; - - for (const metaFile of metaFiles) { - const meta = JSON.parse(await readFile(join(intermediateDir, metaFile), 'utf-8')); - chapters.push(meta); - } - - // Sort chapters by index - chapters.sort((a, b) => a.index - b.index); - - // Create chapter metadata file - const metadata: string[] = []; - let currentTime = 0; - - // Calculate chapter timings based on actual durations - chapters.forEach((chapter) => { - const startMs = Math.floor(currentTime * 1000); - currentTime += chapter.duration; - const endMs = Math.floor(currentTime * 1000); - - metadata.push( - `[CHAPTER]`, - `TIMEBASE=1/1000`, - `START=${startMs}`, - `END=${endMs}`, - `title=${chapter.title}` - ); - }); - - await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n')); - - // Create list file for concat - await writeFile( - listPath, - chapters.map(c => `file '${join(intermediateDir, `${c.index}.wav`)}'`).join('\n') - ); - - // Combine all files into a single M4B - await runFFmpeg([ - '-f', 'concat', - '-safe', '0', - '-i', listPath, - '-i', metadataPath, - '-map_metadata', '1', - '-c:a', 'copy', // c:a is codec for audio and :a is stream specifier - //'-codec', 'wav', - //'-b:a', '192k', - //'-threads', '0', // Use maximum available threads - //'-movflags', '+faststart', - //'-preset', 'ultrafast', // Use fastest encoding preset - outputPath - ]); - - // Stream the file back to the client - const stream = createReadStream(outputPath); - - // Clean up function - const cleanup = async () => { - try { - await Promise.all([ - ...chapters.map(c => unlink(join(intermediateDir, `${c.index}.wav`))), - ...chapters.map(c => unlink(join(intermediateDir, `${c.index}.meta.json`))), - unlink(metadataPath), - unlink(listPath), - unlink(outputPath), - rmdir(intermediateDir) - ]); - } catch (error) { - console.error('Cleanup error:', error); - } + // Create default provider settings + let providerSettings: ProviderSettings = { + openai: { + apiKey: openaiKey, + baseUrl: 'https://api.openai.com/v1', + model: 'tts-1', + }, + ollama: { + baseUrl: 'http://localhost:11434', + model: 'llama3:8b', + }, + openrouter: { + apiKey: openrouterKey, + model: 'openai/whisper', + }, + elevenlabs: { + apiKey: elevenLabsKey, + }, }; - - // Clean up after streaming is complete - stream.on('end', cleanup); - - const readableWebStream = new ReadableStream({ - start(controller) { - stream.on('data', (chunk) => { - controller.enqueue(chunk); - }); - stream.on('end', () => { - controller.close(); - }); - stream.on('error', (err) => { - controller.error(err); - }); - }, + + // Parse provider settings from header if available + try { + const settingsHeader = headers.get('x-provider-settings'); + if (settingsHeader) { + providerSettings = JSON.parse(settingsHeader); + } + } catch (error) { + console.error('Error parsing provider settings:', error); + } + + console.log(`Converting text to audio using provider: ${effectiveProvider}, voice provider: ${effectiveVoiceProvider}, speed: ${speed || 1.0}`); + + // Create voice provider for TTS generation + const ttsProvider = createVoiceProvider(effectiveVoiceProvider, { + providerSettings }); - - return new NextResponse(readableWebStream, { + + if (!ttsProvider) { + throw new Error(`Failed to create voice provider for ${effectiveVoiceProvider}`); + } + + // Ensure speed is a valid number + const effectiveSpeed = typeof speed === 'string' ? parseFloat(speed) : + typeof speed === 'number' ? speed : 1.0; + + // Generate speech from text + const audioBuffer = await ttsProvider.generateSpeech({ + text, + voice: voice || 'alloy', + speed: effectiveSpeed, + format: 'mp3', + }); + + if (!audioBuffer || audioBuffer.byteLength === 0) { + throw new Error('Generated audio buffer is empty'); + } + + // Return audio data + return new Response(audioBuffer, { headers: { - 'Content-Type': 'audio/mp4', + 'Content-Type': 'audio/mpeg', + 'Content-Disposition': `attachment; filename="audio.mp3"`, + 'Cache-Control': 'private, max-age=604800', // Cache for a week }, }); - } catch (error) { - console.error('Error creating M4B:', error); - return NextResponse.json( - { error: 'Failed to create M4B file' }, + console.error('Error converting text to audio:', error); + return Response.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 } ); } diff --git a/src/app/api/providers/ollama/models/route.ts b/src/app/api/providers/ollama/models/route.ts new file mode 100644 index 0000000..26b5870 --- /dev/null +++ b/src/app/api/providers/ollama/models/route.ts @@ -0,0 +1,109 @@ +/** + * Ollama Models API Endpoint + * + * This endpoint fetches available models from an Ollama instance. + */ + +import { NextRequest } from 'next/server'; + +interface OllamaModel { + name: string; + modified_at: string; + size: number; + digest: string; + details: { + format: string; + family: string; + families: string[]; + parameter_size: string; + quantization_level: string; + }; +} + +interface OllamaModelsResponse { + models: OllamaModel[]; +} + +export async function GET(req: NextRequest) { + try { + // Get the base URL from the query string or use the default + const searchParams = req.nextUrl.searchParams; + const baseUrl = searchParams.get('baseUrl') || 'http://localhost:11434'; + + // Normalize the base URL (ensure it doesn't end with a slash) + const normalizedBaseUrl = baseUrl.endsWith('/') + ? baseUrl.slice(0, -1) + : baseUrl; + + // Make request to Ollama API to fetch models + const response = await fetch(`${normalizedBaseUrl}/api/tags`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + // Set a timeout of 5 seconds + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + // Handle specific error cases + if (response.status === 404) { + return Response.json({ + error: 'Not found', + message: 'Ollama API endpoint not found' + }, { status: 404 }); + } + + const errorText = await response.text(); + return Response.json({ + error: 'Ollama API error', + message: errorText + }, { status: response.status }); + } + + const data: OllamaModelsResponse = await response.json(); + + // Transform the data to a simpler format + const models = data.models.map(model => ({ + id: model.name, + name: model.name, + details: model.details ? { + family: model.details.family, + parameterSize: model.details.parameter_size, + } : undefined, + size: model.size, + })); + + // If no models are found, return a specific message + if (models.length === 0) { + return Response.json({ + models: [], + message: 'No models found in Ollama instance' + }); + } + + return Response.json({ models }); + } catch (error) { + console.error('Error fetching Ollama models:', error); + + // Handle different error types + if (error instanceof TypeError && error.message.includes('fetch')) { + return Response.json({ + error: 'Connection error', + message: 'Could not connect to Ollama instance' + }, { status: 503 }); + } + + if (error instanceof DOMException && error.name === 'AbortError') { + return Response.json({ + error: 'Timeout', + message: 'Connection to Ollama timed out' + }, { status: 504 }); + } + + return Response.json({ + error: 'Unknown error', + message: error instanceof Error ? error.message : 'An unknown error occurred' + }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 255748a..63de591 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -1,59 +1,138 @@ -import { NextRequest, NextResponse } from 'next/server'; -import OpenAI from 'openai'; +/** + * TTS API Route + * + * This API route handles requests to generate speech from text using various providers. + */ -export async function POST(req: NextRequest) { +import type { NextRequest } from 'next/server'; +import { + createProvider, + createVoiceProvider +} from '@/providers/factory'; +import { + ProviderType, + VoiceProviderType, + ProviderSettings +} from '@/providers/types'; + +/** + * Handles requests to generate speech from text using the specified provider + * + * @param req The incoming request + * @returns A response containing the generated audio + */ +export async function POST(req: Request) { try { - // Get API credentials from headers or fall back to environment variables - const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; - const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; - const { text, voice, speed, format } = await req.json(); - console.log('Received TTS request:', text, voice, speed, format); - - if (!openApiKey) { - return NextResponse.json({ error: 'Missing OpenAI API key' }, { status: 401 }); - } - - if (!text || !voice || !speed) { - return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 }); - } - - // Initialize OpenAI client with abort signal - const openai = new OpenAI({ - apiKey: openApiKey, - baseURL: openApiBaseUrl, - }); - - // Request audio from OpenAI and pass along the abort signal - const response = await openai.audio.speech.create({ - model: 'tts-1', - voice: voice as "alloy", - input: text, - speed: speed, - response_format: format === 'aac' ? 'aac' : 'mp3', - }, { signal: req.signal }); // Pass the abort signal to OpenAI client - - // Get the audio data as array buffer - // This will also be aborted if the client cancels - const stream = response.body; - - // Return audio data with appropriate headers - const contentType = format === 'aac' ? 'audio/aac' : 'audio/mpeg'; - return new NextResponse(stream, { - headers: { - 'Content-Type': contentType + // Create a new abort controller for this request + const controller = new AbortController(); + const { signal } = controller; + + // Get provider details from request body and headers + const body = await req.json(); + const { + text, + voice, + speed, + provider: bodyProvider, + voiceProvider: bodyVoiceProvider, + documentId + } = body; + + // Check if provider is specified in body, otherwise use header + const provider = (bodyProvider || new Headers(req.headers).get('x-provider') || 'openai') as ProviderType; + const voiceProvider = (bodyVoiceProvider || new Headers(req.headers).get('x-voice-provider') || 'openai') as VoiceProviderType; + + // Parse provider settings from headers or use defaults + let providerSettings: ProviderSettings = { + openai: { + apiKey: new Headers(req.headers).get('x-openai-key') || '', + baseUrl: new Headers(req.headers).get('x-openai-base-url') || 'https://api.openai.com/v1', + model: 'tts-1', + }, + ollama: { + baseUrl: 'http://localhost:11434', + model: 'llama3:8b', + }, + openrouter: { + apiKey: '', + model: 'openai/whisper', + }, + elevenlabs: { + apiKey: '', + }, + }; + + try { + const settingsHeader = new Headers(req.headers).get('x-provider-settings'); + if (settingsHeader) { + providerSettings = JSON.parse(settingsHeader); } - }); - } catch (error) { - // Check if this was an abort error - if (error instanceof Error && error.name === 'AbortError') { - console.log('TTS request aborted by client'); - return new NextResponse(null, { status: 499 }); // Use 499 status for client closed request + } catch (error) { + console.error('Error parsing provider settings:', error); } - - console.warn('Error generating TTS:', error); - return NextResponse.json( - { error: 'Failed to generate audio' }, - { status: 500 } + + // Validate request parameters + if (!text) { + return new Response(JSON.stringify({ error: 'Text is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + if (!voice) { + return new Response(JSON.stringify({ error: 'Voice is required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + // Ensure speed is a valid number + const effectiveSpeed = typeof speed === 'string' ? parseFloat(speed) : + typeof speed === 'number' ? speed : 1.0; + + console.log(`Generating TTS with ${provider}/${voiceProvider} for text at speed ${effectiveSpeed}:`, text.substring(0, 50) + (text.length > 50 ? '...' : '')); + + // Create the appropriate provider instance based on voice provider selection + const ttsProvider = voiceProvider === 'elevenlabs' + ? createVoiceProvider(voiceProvider, { providerSettings }) + : createProvider(provider, { providerSettings }); + + // Set timeout to abort long-running requests + const timeout = setTimeout(() => { + controller.abort(); + console.log('TTS request timed out'); + }, 30000); // 30 second timeout + + try { + // Generate speech + const audioBuffer = await ttsProvider.generateSpeech({ + text, + voice, + speed: effectiveSpeed, + }, signal); + + clearTimeout(timeout); + + // Return audio as binary response + return new Response(audioBuffer, { + headers: { + 'Content-Type': 'audio/mpeg', + 'Cache-Control': 'private, max-age=604800', // Cache for a week + }, + }); + } catch (error) { + clearTimeout(timeout); + throw error; // Re-throw to be caught by the outer try/catch + } + } catch (error) { + // Return error response + console.error('Error generating TTS:', error); + return new Response( + JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } ); } } \ No newline at end of file diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 75947cd..cc89dda 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -1,30 +1,91 @@ -import { NextRequest, NextResponse } from 'next/server'; +/** + * Voices API Route + * + * This API route handles requests to fetch available voices from various providers. + */ -const DEFAULT_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']; +import type { NextRequest } from 'next/server'; +import { + createProvider, + createVoiceProvider +} from '@/providers/factory'; +import { + ProviderType, + VoiceProviderType, + ProviderSettings +} from '@/providers/types'; -export async function GET(req: NextRequest) { +/** + * Handles requests to fetch available voices from specified provider + * + * @param req The incoming request + * @returns A response containing the available voices and categories + */ +export async function GET(req: Request) { try { - // Get API credentials from headers or fall back to environment variables - const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none'; - const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE; - - // Request voices from OpenAI - const response = await fetch(`${openApiBaseUrl}/audio/voices`, { - headers: { - 'Authorization': `Bearer ${openApiKey}`, - 'Content-Type': 'application/json', + const headers = new Headers(req.headers); + + // Get provider details from headers + const provider = (headers.get('x-provider') || 'openai') as ProviderType; + const voiceProvider = (headers.get('x-voice-provider') || 'openai') as VoiceProviderType; + + // Parse provider settings from headers + let providerSettings: ProviderSettings = { + openai: { + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + model: 'tts-1', }, - }); - - if (!response.ok) { - throw new Error('Failed to fetch voices'); + ollama: { + baseUrl: 'http://localhost:11434', + model: 'llama3:8b', + }, + openrouter: { + apiKey: '', + model: 'openai/whisper', + }, + elevenlabs: { + apiKey: '', + }, + }; + + try { + const settingsHeader = headers.get('x-provider-settings'); + if (settingsHeader) { + providerSettings = JSON.parse(settingsHeader); + } + } catch (error) { + console.error('Error parsing provider settings:', error); } - - const data = await response.json(); - return NextResponse.json({ voices: data.voices || DEFAULT_VOICES }); + + console.log(`Fetching voices for ${voiceProvider} provider...`); + + // Create the appropriate provider instance + const ttsProvider = voiceProvider === 'elevenlabs' + ? createVoiceProvider(voiceProvider, { providerSettings }) + : createProvider(provider, { providerSettings }); + + // Fetch voices and categories + const [voices, categories] = await Promise.all([ + ttsProvider.getAvailableVoices(), + ttsProvider.getVoiceCategories() + ]); + + return new Response( + JSON.stringify({ voices, categories }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); } catch (error) { - console.error('Error fetching voices:', error); - // Return default voices on error - return NextResponse.json({ voices: DEFAULT_VOICES }); + console.error('Error getting voices:', error); + return new Response( + JSON.stringify({ error: error instanceof Error ? error.message : 'Unknown error' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' } + } + ); } } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 116bb5e..7127a88 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,71 +1,47 @@ -import "./globals.css"; -import { ReactNode } from "react"; -import { Providers } from "@/app/providers"; -import { Metadata } from "next"; -import { Footer } from "@/components/Footer"; -import { Toaster } from 'react-hot-toast'; +'use client'; -export const metadata: Metadata = { - title: "OpenReader WebUI", - description: 'A "bring your own TTS api" web interface for reading PDF and EPUB documents with high quality text-to-speech voices. Read books with ease, listen to articles on the go, or study like you have your own lecturer, all in one place.', - keywords: "PDF reader, EPUB reader, text to speech, tts open ai, kokoro tts, Kokoro-82M, OpenReader, TTS PDF reader, ebook reader, epub tts, high quality text to speech", - authors: [{ name: "Richard Roberson" }], - manifest: "/manifest.json", - metadataBase: new URL("https://openreader.richardr.dev"), // Replace with your domain - openGraph: { - type: "website", - locale: "en_US", - url: "https://openreader.richardr.dev", - siteName: "OpenReader WebUI", - title: "OpenReader WebUI", - description: 'A "bring your own TTS api" web interface for reading PDF and EPUB documents with high quality text-to-speech voices. Read books with ease, listen to articles on the go, or study like you have your own lecturer, all in one place.', - images: [ - { - url: "/web-app-manifest-512x512.png", - width: 512, - height: 512, - alt: "OpenReader WebUI Logo", - }, - ], - }, - robots: { - index: true, - follow: true, - googleBot: { - index: true, - follow: true, - 'max-video-preview': -1, - 'max-image-preview': 'large', - 'max-snippet': -1, - }, - }, - verification: { - // Add your verification codes if you have them - google: "MJXyTudn1kgQF8EtGD-tsnAWev7Iawso9hEvqeGHB3U", - }, -}; +import { useState, useEffect } from 'react'; +import { Providers } from './providers'; +import './globals.css'; -const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; -//const isDev = false; +/** + * Root layout component for the application + */ +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + // Client-side only state to avoid hydration mismatches + const [isClient, setIsClient] = useState(false); + + // Set isClient to true once component mounts on the client + useEffect(() => { + setIsClient(true); + }, []); -export default function RootLayout({ children }: { children: ReactNode }) { return ( - + - + + + + - - -
-
-
- {children} -
- {!isDev &&
} -
+ + {/* Only render the application content once mounted on the client */} + {isClient ? ( + {children} + ) : ( + // Add a simple loading state while client-side JS initializes +
+
Loading OpenReader...
- - + )} ); diff --git a/src/app/metadata.ts b/src/app/metadata.ts new file mode 100644 index 0000000..d41fe87 --- /dev/null +++ b/src/app/metadata.ts @@ -0,0 +1,18 @@ +import { Metadata } from 'next'; + +/** + * Metadata for the application + */ +export const metadata: Metadata = { + title: 'OpenReader', + description: 'Read and listen to documents with multiple AI providers', + icons: { + icon: '/favicon.ico', + }, + manifest: '/manifest.json', + viewport: { + width: 'device-width', + initialScale: 1, + }, + themeColor: '#000000', +}; \ No newline at end of file diff --git a/src/app/page-metadata.tsx b/src/app/page-metadata.tsx new file mode 100644 index 0000000..d19cd36 --- /dev/null +++ b/src/app/page-metadata.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from 'next'; + +/** + * Metadata for the application + * This needs to be exported from a server component + */ +export const metadata: Metadata = { + title: 'OpenReader', + description: 'Read and listen to documents with multiple AI providers', + icons: { + icon: '/favicon.ico', + }, + viewport: { + width: 'device-width', + initialScale: 1, + }, + themeColor: '#000000', +}; + +/** + * This component can be imported by pages to set their metadata + */ +export default function PageMetadata() { + return null; +} \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 2b74e42..678229a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,17 +1,30 @@ -import { DocumentUploader } from '@/components/DocumentUploader'; import { DocumentList } from '@/components/doclist/DocumentList'; +import { DocumentUploader } from '@/components/DocumentUploader'; +import { Footer } from '@/components/Footer'; import { SettingsModal } from '@/components/SettingsModal'; +import { metadata } from './page-metadata'; +/** + * Home page component + * Shows document list and uploader + */ export default function Home() { return ( -
- -

OpenReader WebUI

-

A bring your own text-to-speech api web interface for reading documents with high quality voices

-
- - +
+
+
+ +

+ OpenReader +

+ + +
-
+