diff --git a/README.md b/README.md index b7c0261..efcd843 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,11 @@ 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). -- 🎯 **TTS API Integration**: Compatible with OpenAI text to speech API, Kokoro FastAPI TTS, or any other compatible service; enabling high-quality voice narration +- 🎯 **TTS API Integration**: + - Compatible with OpenAI text to speech API and GPT-4o Mini TTS, Kokoro-FastAPI TTS, or any other compatible service + - Support for multiple TTS models (tts-1, tts-1-hd, gpt-4o-mini-tts, kokoro) + - Custom model support for experimental or self-hosted models + - Model-specific instructions support (for gpt-4o-mini-tts) - 💾 **Local-First Architecture**: Uses IndexedDB browser storage for documents - 🛜 **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 @@ -22,6 +26,8 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering - 📲 **Mobile Support**: Works on mobile devices, and can be added as a PWA web app - 🎨 **Customizable Experience**: - 🔑 Set TTS API base URL (and optional API key) + - 🤖 Choose from multiple TTS models or use custom models + - 🎯 Set model-specific instructions for GPT-4o Mini TTS - 🏎️ Adjustable playback speed - 📐 Customize PDF text extraction margins - 🗣️ Multiple voice options (checks `/v1/audio/voices` endpoint) @@ -31,6 +37,7 @@ OpenReader WebUI is a document reader with Text-to-Speech capabilities, offering ### 🛠️ Work in progress - [x] **Audiobook creation and download** (m4b format) - [x] **Get PDFs on iOS 17 and below working 🤞** +- [x] **Support for GPT-4o Mini TTS with instructions** - [ ] **End-to-end Testing**: More playwright tests (in progress) - [ ] **More document formats**: .txt, .md - [ ] **Support more TTS APIs**: ElevenLabs, etc. diff --git a/package.json b/package.json index 865dc05..52f497c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openreader-webui", - "version": "0.2.4-patch.4", + "version": "0.2.5", "private": true, "scripts": { "dev": "next dev --turbopack -p 3003", diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index ee5be18..bde4ab7 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -1,12 +1,19 @@ import { NextRequest, NextResponse } from 'next/server'; import OpenAI from 'openai'; +import { SpeechCreateParams } from 'openai/resources/audio/speech.mjs'; + +type CustomVoice = string; +type ExtendedSpeechParams = Omit & { + voice: SpeechCreateParams['voice'] | CustomVoice; + instructions?: string; +}; export async function POST(req: NextRequest) { 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, model } = await req.json(); + const { text, voice, speed, format, model, instructions } = await req.json(); console.log('Received TTS request:', text, voice, speed, format, model); if (!openApiKey) { @@ -24,13 +31,20 @@ export async function POST(req: NextRequest) { }); // Request audio from OpenAI and pass along the abort signal - const response = await openai.audio.speech.create({ + const createParams: ExtendedSpeechParams = { model: 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 + }; + + // Only add instructions if model is gpt-4o-mini-tts and instructions are provided + if (model === 'gpt-4o-mini-tts' && instructions) { + createParams.instructions = instructions; + } + + const response = await openai.audio.speech.create(createParams as SpeechCreateParams, { signal: req.signal }); // Get the audio data as array buffer // This will also be aborted if the client cancels diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index de6e67e..bbbb7fb 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Fragment, useState, useEffect, useCallback } from 'react'; +import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; import { Dialog, DialogPanel, @@ -39,17 +39,27 @@ export function SettingsModal() { const [isOpen, setIsOpen] = useState(false); const { theme, setTheme } = useTheme(); - const { apiKey, baseUrl, ttsModel, updateConfig, updateConfigKey } = useConfig(); + const { apiKey, baseUrl, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = useDocuments(); const [localApiKey, setLocalApiKey] = useState(apiKey); const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl); const [localTTSModel, setLocalTTSModel] = useState(ttsModel); + const [customModel, setCustomModel] = useState(''); + const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions); const [isSyncing, setIsSyncing] = useState(false); const [isLoading, setIsLoading] = useState(false); const selectedTheme = themes.find(t => t.id === theme) || themes[0]; const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false); const [showClearServerConfirm, setShowClearServerConfirm] = useState(false); + const ttsModels = useMemo(() => [ + { id: 'tts-1', name: 'TTS-1' }, + { id: 'tts-1-hd', name: '($$) TTS-1-HD' }, + { id: 'gpt-4o-mini-tts', name: '($$$) GPT-4o Mini TTS' }, + { id: 'kokoro', name: 'Kokoro' }, + { id: 'custom', name: 'Custom Model' } + ], []); + // set firstVisit on initial load const checkFirstVist = useCallback(async () => { if (!isDev) return; @@ -65,7 +75,13 @@ export function SettingsModal() { setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalTTSModel(ttsModel); - }, [apiKey, baseUrl, ttsModel, checkFirstVist]); + setLocalTTSInstructions(ttsInstructions); + // Set custom model if current model is not in predefined list + if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') { + setCustomModel(ttsModel); + setLocalTTSModel('custom'); + } + }, [apiKey, baseUrl, ttsModel, ttsModels, ttsInstructions, checkFirstVist]); const handleSync = async () => { try { @@ -120,6 +136,18 @@ export function SettingsModal() { } }; + const resetToCurrent = useCallback(() => { + setIsOpen(false); + setLocalApiKey(apiKey); + setLocalBaseUrl(baseUrl); + setLocalTTSModel(ttsModel); + setLocalTTSInstructions(ttsInstructions); + if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') { + setCustomModel(ttsModel); + setLocalTTSModel('custom'); + } + }, [apiKey, baseUrl, ttsModel, ttsInstructions, ttsModels]); + const tabs = [ { name: 'Appearance', icon: '✨' }, { name: 'API', icon: '🔑' }, @@ -136,11 +164,7 @@ export function SettingsModal() { - { - setIsOpen(false); - setLocalApiKey(apiKey); - setLocalBaseUrl(baseUrl); - }}> + - + {tabs.map((tab) => (
- -
- handleInputChange('ttsModel', e.target.value)} - placeholder="tts-1" - className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" - /> + +
+ m.id === localTTSModel) || ttsModels[0]} + onChange={(model) => { + setLocalTTSModel(model.id); + if (model.id !== 'custom') { + setCustomModel(''); + } + }} + > + + + {ttsModels.find(m => m.id === localTTSModel)?.name || 'Select Model'} + + + + + + + + {ttsModels.map((model) => ( + + `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ + active ? 'bg-accent/10 text-accent' : 'text-foreground' + }` + } + value={model} + > + {({ selected }) => ( + <> + + {model.name} + + {selected ? ( + + + + ) : null} + + )} + + ))} + + + + + {localTTSModel === 'custom' && ( + setCustomModel(e.target.value)} + placeholder="Enter custom model name" + className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" + /> + )}
+ {localTTSModel === 'gpt-4o-mini-tts' && ( +
+ +