diff --git a/README.md b/README.md index 06704d0..f233826 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader ## ✨ Highlights -- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (OpenAI, Replicate, DeepInfra, Kokoro, KittenTTS-FastAPI, Orpheus, custom). +- 🎯 **Multi-provider TTS** with OpenAI-compatible endpoints and cloud providers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI or OpenAI, Replicate, DeepInfra). - 📖 **Read-along playback** for PDF/EPUB with sentence-aware narration. - ⏱️ **Word-by-word highlighting** via optional `whisper.cpp` timestamps. - 🛜 **Sync + library import** to bring docs across devices and from server-mounted folders. diff --git a/docs-site/docs/configure/tts-provider-guides/replicate.md b/docs-site/docs/configure/tts-provider-guides/replicate.md index b1e2c5c..14cdaf6 100644 --- a/docs-site/docs/configure/tts-provider-guides/replicate.md +++ b/docs-site/docs/configure/tts-provider-guides/replicate.md @@ -11,7 +11,7 @@ Use Replicate's hosted TTS models as your provider. ```env API_KEY=r8_... NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=replicate -NEXT_PUBLIC_DEFAULT_TTS_MODEL=google/gemini-3.1-flash-tts +NEXT_PUBLIC_DEFAULT_TTS_MODEL=alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5 ``` **Or in-app via Settings -> TTS Provider:** @@ -25,11 +25,12 @@ Settings modal values override env vars. See [TTS Providers](../tts-providers) f ## Notes - Built-in Replicate models: + - `alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5` - `google/gemini-3.1-flash-tts` - `minimax/speech-2.8-turbo` - `qwen/qwen3-tts` - `inworld/tts-1.5-mini` -- You can also choose `Other` and enter any Replicate model ID (for example `owner/model-name`). +- You can also choose `Other` and enter any Replicate model ID (for example `owner/model-name` or `owner/model-name:version`). - Native model speed is not available on all Replicate models; OpenReader hides/disables native speed controls where unsupported. - TTS requests are sent from the server, not the browser. The API key is never exposed to clients. diff --git a/docs-site/docs/configure/tts-providers.md b/docs-site/docs/configure/tts-providers.md index ef50dd0..89bd942 100644 --- a/docs-site/docs/configure/tts-providers.md +++ b/docs-site/docs/configure/tts-providers.md @@ -16,16 +16,16 @@ Set env vars as deployment-level defaults. Users (or you, in a single-user setup - **OpenAI**: Cloud. Base URL pre-filled (`https://api.openai.com/v1`). API key required. - **Replicate**: Cloud. Base URL managed internally by OpenReader. API key required. -- **Deepinfra**: Cloud. Base URL pre-filled (`https://api.deepinfra.com/v1/openai`). API key required. +- **DeepInfra**: Cloud. Base URL pre-filled (`https://api.deepinfra.com/v1/openai`). API key required. - **Custom OpenAI-Like**: Self-hosted or any custom endpoint. `API_BASE` must be set manually (typically ending in `/v1`). API key optional. -For `OpenAI`, `Deepinfra`, and `Replicate` you only need to supply an API key. For `Custom OpenAI-Like` you must also set `API_BASE`. +For `OpenAI`, `DeepInfra`, and `Replicate` you only need to supply an API key. For `Custom OpenAI-Like` you must also set `API_BASE`. ## Built-in model catalogs -- **Replicate** models: `google/gemini-3.1-flash-tts`, `minimax/speech-2.8-turbo`, `qwen/qwen3-tts`, `inworld/tts-1.5-mini` (or choose `Other` and enter any Replicate model ID) +- **Replicate** models: `alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5`, `google/gemini-3.1-flash-tts`, `minimax/speech-2.8-turbo`, `qwen/qwen3-tts`, `inworld/tts-1.5-mini` (or choose `Other` and enter any Replicate model ID, such as `owner/model` or `owner/model:version`) - **OpenAI** models: `tts-1`, `tts-1-hd`, `gpt-4o-mini-tts` -- **Deepinfra** models: includes `hexgrad/Kokoro-82M` and additional hosted models (depending on API key / feature flags) +- **DeepInfra** models: includes `hexgrad/Kokoro-82M` and additional hosted models (depending on API key / feature flags) ## Custom provider requirements diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 0d63344..c5a7e0c 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -18,8 +18,7 @@ This guide covers deploying OpenReader to Vercel with external Postgres and S3-c Recommended production setup (auth enabled): ```bash -API_BASE=https://api.deepinfra.com/v1/openai -API_KEY=your_deepinfra_key +API_KEY=your_replicate_key POSTGRES_URL=postgres://... USE_EMBEDDED_WEED_MINI=false S3_ACCESS_KEY_ID=... @@ -33,8 +32,8 @@ AUTH_SECRET=... NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=false NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=false NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=false -NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=deepinfra -NEXT_PUBLIC_DEFAULT_TTS_MODEL=hexgrad/Kokoro-82M +NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=replicate +NEXT_PUBLIC_DEFAULT_TTS_MODEL=alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5 NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=false NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false @@ -50,7 +49,7 @@ We recommend setting these defaults for a production-like environment: - `NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=false`: Hides destructive "Delete All" actions - `NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=false`: Hides the Settings -> TTS Provider section - `NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=replicate`: Points default TTS to a scalable provider -- `NEXT_PUBLIC_DEFAULT_TTS_MODEL=google/gemini-3.1-flash-tts`: Uses a high-quality default model +- `NEXT_PUBLIC_DEFAULT_TTS_MODEL=alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5`: Uses a low-cost default model - `NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=false`: Restricts usage to free models if no key is provided - `NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT=true`: (Optional) Controls audiobook export UI - `NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT=false`: (Optional) Controls word highlighting UI (requires timestamp backend) diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index b7f4a6f..79a617d 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -18,7 +18,7 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c - [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI) - **Custom OpenAI-compatible**: any TTS API with `/v1/audio/voices` and `/v1/audio/speech` endpoints - **Cloud TTS providers**: - - [**Replicate**](https://replicate.com/explore): `google/gemini-3.1-flash-tts`, `minimax/speech-2.8-turbo`, `qwen/qwen3-tts`, and `inworld/tts-1.5-mini` + - [**Replicate**](https://replicate.com/explore): includes a built-in catalog and supports any Replicate model ID via `Other` - [**DeepInfra**](https://deepinfra.com/models/text-to-speech): Kokoro-82M and other hosted models - [**OpenAI API**](https://platform.openai.com/docs/pricing#transcription-and-speech): `tts-1`, `tts-1-hd`, and `gpt-4o-mini-tts` - 🛜 **Server-side Document Storage** diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 61374bf..24e157a 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -307,9 +307,16 @@ export async function POST(request: NextRequest) { existingSettings = null; } + const normalizedExistingSettings = existingSettings ? normalizeNativeSpeedForSettings(existingSettings) : undefined; const incomingSettings = data.settings ? normalizeNativeSpeedForSettings(data.settings) : undefined; - if (existingSettings && hasChapters && incomingSettings) { - const normalizedExistingSettings = normalizeNativeSpeedForSettings(existingSettings); + const mergedSettings = (normalizedExistingSettings || incomingSettings) + ? normalizeNativeSpeedForSettings({ + ...(normalizedExistingSettings || {}), + ...(incomingSettings || {}), + } as AudiobookGenerationSettings) + : undefined; + + if (normalizedExistingSettings && hasChapters && incomingSettings) { const mismatch = normalizedExistingSettings.ttsProvider !== incomingSettings.ttsProvider || normalizedExistingSettings.ttsModel !== incomingSettings.ttsModel || @@ -330,10 +337,9 @@ export async function POST(request: NextRequest) { const format: TTSAudiobookFormat = (existingFormats.values().next().value as TTSAudiobookFormat | undefined) ?? - existingSettings?.format ?? - incomingSettings?.format ?? + mergedSettings?.format ?? requestedFormat; - const rawPostSpeed = incomingSettings?.postSpeed ?? existingSettings?.postSpeed ?? 1; + const rawPostSpeed = mergedSettings?.postSpeed ?? 1; const postSpeed = Number.isFinite(Number(rawPostSpeed)) ? Number(rawPostSpeed) : 1; let chapterIndex: number; @@ -357,22 +363,20 @@ export async function POST(request: NextRequest) { } const provider = request.headers.get('x-tts-provider') - || incomingSettings?.ttsProvider - || existingSettings?.ttsProvider + || mergedSettings?.ttsProvider || 'openai'; const openApiKey = request.headers.get('x-openai-key') || process.env.API_KEY || 'none'; const openApiBaseUrl = request.headers.get('x-openai-base-url') || process.env.API_BASE; - const model = incomingSettings?.ttsModel ?? existingSettings?.ttsModel; - const voice = incomingSettings?.voice - || existingSettings?.voice + const model = mergedSettings?.ttsModel; + const voice = mergedSettings?.voice || (provider === 'openai' ? 'alloy' : provider === 'deepinfra' ? 'af_bella' : 'af_sarah'); - const rawNativeSpeed = incomingSettings?.nativeSpeed ?? existingSettings?.nativeSpeed ?? 1; + const rawNativeSpeed = mergedSettings?.nativeSpeed ?? 1; const nativeSpeed = Number.isFinite(Number(rawNativeSpeed)) ? Number(rawNativeSpeed) : 1; - const instructions = incomingSettings?.ttsInstructions ?? existingSettings?.ttsInstructions; + const instructions = mergedSettings?.ttsInstructions; if (authEnabled && userId && isTtsRateLimitEnabled()) { const isAnonymous = Boolean(user?.isAnonymous); diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 7983393..0843e1e 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -66,8 +66,14 @@ function getUpstreamRetryAfterSeconds(error: unknown): number | undefined { const retryAfterHeader = response?.headers?.get?.('retry-after'); if (!retryAfterHeader) return undefined; const parsed = Number(retryAfterHeader); - if (!Number.isFinite(parsed) || parsed <= 0) return undefined; - return Math.ceil(parsed); + if (Number.isFinite(parsed) && parsed > 0) { + return Math.ceil(parsed); + } + const parsedDateMs = Date.parse(retryAfterHeader); + if (!Number.isFinite(parsedDateMs)) return undefined; + const seconds = (parsedDateMs - Date.now()) / 1000; + if (seconds <= 0) return undefined; + return Math.ceil(seconds); } export async function POST(req: NextRequest) { diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index bf01a68..6a8d560 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -36,7 +36,7 @@ import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/a import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; -import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; +import { REPLICATE_KOKORO_82M_VERSIONED_MODEL, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; const enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false'; const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== 'false'; @@ -392,6 +392,12 @@ export function SettingsModal({ className = '' }: { className?: string }) { const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`; const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`; const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.02]`; + const shouldShowBaseUrl = localTTSProvider !== 'replicate' + && (localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === ''); + const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]; + const selectedModelVersion = selectedModel?.id?.includes(':') + ? selectedModel.id.slice(selectedModel.id.indexOf(':')) + : ''; return ( <> @@ -508,7 +514,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { setModelValue('kokoro'); setLocalBaseUrl(''); } else if (provider.id === 'replicate') { - setModelValue('google/gemini-3.1-flash-tts'); + setModelValue(REPLICATE_KOKORO_82M_VERSIONED_MODEL); setLocalBaseUrl(''); } else if (provider.id === 'deepinfra') { setModelValue('hexgrad/Kokoro-82M'); @@ -562,7 +568,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { - {localTTSProvider !== 'replicate' && (localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && ( + {shouldShowBaseUrl && (