From 379e5082f048797917202f9f642e79d061671049 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 6 Jun 2026 12:19:28 -0600 Subject: [PATCH] docs(intro): clarify open-source status and multilingual support in introduction Update introduction documentation and README to consistently refer to OpenReader as "open-source" and highlight its multilingual capabilities. Refine language segmentation example in multilingual sample text and expand test coverage for language settings. Adjust TTS segment manifest to normalize language tags, improve document language update logic, and fix provider/model compatibility checks for instructions and language input. Enhance reliability of language support detection for TTS generation and Replicate provider integration. --- README.md | 2 +- docs-site/docs/introduction.md | 2 +- src/app/api/tts/segments/manifest/route.ts | 3 ++- src/components/admin/AdminProvidersPanel.tsx | 8 ++++---- src/components/reader/SegmentsSidebar.tsx | 4 +++- src/hooks/useDocumentLanguage.ts | 15 +++++++++------ src/lib/server/tts/generate.ts | 2 +- src/lib/server/tts/voice-resolution.ts | 2 +- tests/files/multilingual-sample.txt | 2 +- tests/unit/audiobook-settings.vitest.spec.ts | 2 ++ 10 files changed, 25 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index f6a5b48..1e8e5c6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ # 📄🔊 OpenReader -OpenReader is an open source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with multilingual, synchronized read-along playback. +OpenReader is an open-source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with multilingual, synchronized read-along playback. > Previously named **OpenReader-WebUI**. diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index a255ce3..8f60592 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -4,7 +4,7 @@ title: Introduction slug: / --- -OpenReader is an open source text-to-speech document reader built with Next.js. It provides a multilingual read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**. +OpenReader is an open-source text-to-speech document reader built with Next.js. It provides a multilingual read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**. > Previously named **OpenReader-WebUI**. diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts index 686555c..f33155d 100644 --- a/src/app/api/tts/segments/manifest/route.ts +++ b/src/app/api/tts/segments/manifest/route.ts @@ -18,6 +18,7 @@ import type { TTSSegmentsManifestResponse, } from '@/types/client'; import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog'; +import { normalizeLanguageTag } from '@/lib/shared/language'; import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy'; import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; import { createRequestLogger } from '@/lib/server/logger'; @@ -71,7 +72,7 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null { const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1; const instructionsSource = rec.ttsInstructions ?? rec.instructions; const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : ''; - const language = typeof rec.language === 'string' ? rec.language : 'en'; + const language = typeof rec.language === 'string' ? normalizeLanguageTag(rec.language, 'en') : 'en'; if (!providerRef || !providerType || !ttsModel || !voice) return null; return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions, language }; diff --git a/src/components/admin/AdminProvidersPanel.tsx b/src/components/admin/AdminProvidersPanel.tsx index ef92a1d..4a2f202 100644 --- a/src/components/admin/AdminProvidersPanel.tsx +++ b/src/components/admin/AdminProvidersPanel.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons'; -import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; +import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { Badge, @@ -327,9 +327,9 @@ export function AdminProvidersPanel() { ? 'custom' : modelDefinitions[0]?.id ?? ''; const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId); - const modelSupportsInstructions = useCallback((model: string) => resolveTtsProviderModelPolicy({ + const modelSupportsInstructions = useCallback((model: string, providerType: TtsProviderType = form.providerType) => resolveTtsProviderModelPolicy({ providerRef: form.slug, - providerType: form.providerType, + providerType, model, }).supportsInstructions, [form.slug, form.providerType]); const baseUrlPlaceholder = form.providerType === 'custom-openai' @@ -425,7 +425,7 @@ export function AdminProvidersPanel() { providerType: opt.value, baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '', defaultModel: nextModel, - defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '', + defaultInstructions: modelSupportsInstructions(nextModel, opt.value) ? form.defaultInstructions : '', }); setCustomModelInput(''); }} diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx index 835fe98..33d2434 100644 --- a/src/components/reader/SegmentsSidebar.tsx +++ b/src/components/reader/SegmentsSidebar.tsx @@ -188,6 +188,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: activeReaderType, clearSegmentCaches, resolvedLanguage, + setDocumentLanguage, } = useTTS(); const { providerRef, @@ -421,7 +422,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }: updateConfigKey('voiceSpeed', Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1), updateConfigKey('ttsInstructions', settings.ttsInstructions || ''), ]); - }, [updateConfigKey]); + if (settings.language) setDocumentLanguage(settings.language); + }, [updateConfigKey, setDocumentLanguage]); const handleRefresh = useCallback(() => { didAutoScrollOnOpenRef.current = false; diff --git a/src/hooks/useDocumentLanguage.ts b/src/hooks/useDocumentLanguage.ts index f8474ab..c8ca980 100644 --- a/src/hooks/useDocumentLanguage.ts +++ b/src/hooks/useDocumentLanguage.ts @@ -31,19 +31,22 @@ export function useDocumentLanguage(documentId: string | undefined): { const updateLanguage = useCallback(async (language: string): Promise => { if (!documentId) return; - const next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, { - ...settings, - schemaVersion: 1, - language, + let next = DEFAULT_DOCUMENT_SETTINGS; + setSettings((prev) => { + next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, { + ...prev, + schemaVersion: 1, + language, + }); + return next; }); - setSettings(next); try { const response = await putDocumentSettings(documentId, next); setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings)); } catch (error) { console.warn('Failed to persist document language:', error); } - }, [documentId, settings]); + }, [documentId]); return { language: settings.language ?? 'auto', diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index c235e88..cfca3d3 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -644,7 +644,7 @@ async function runProviderRequest( } if (request.provider !== 'openai' && request.language) { - const supportKey = `${request.baseUrl || ''}|${request.model as string}`; + const supportKey = `${request.provider}|${request.baseUrl || ''}|${request.model as string}|${request.language}`; if (!openAiCompatibleLanguageUnsupported.has(supportKey)) { try { return await fetchTTSBufferWithRetry( diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts index 1fd9420..7d80a13 100644 --- a/src/lib/server/tts/voice-resolution.ts +++ b/src/lib/server/tts/voice-resolution.ts @@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({ model, apiKey = '', }: ResolveVoicesOptions): Promise { - if (provider !== 'replicate' || !apiKey) return null; + if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) return null; const cached = replicateLanguageInputCache.get(model); if (cached) return cached; diff --git a/tests/files/multilingual-sample.txt b/tests/files/multilingual-sample.txt index fb517aa..5f9436c 100644 --- a/tests/files/multilingual-sample.txt +++ b/tests/files/multilingual-sample.txt @@ -2,7 +2,7 @@ English OpenReader should split this sentence correctly. This is the second sentence. French -OpenReader doit correctement segmenter cette phrase. Voici la deuxieme phrase. +OpenReader doit correctement segmenter cette phrase. Voici la deuxième phrase. Spanish OpenReader debe segmentar correctamente esta frase. Esta es la segunda frase. diff --git a/tests/unit/audiobook-settings.vitest.spec.ts b/tests/unit/audiobook-settings.vitest.spec.ts index 9cd68fe..4417d01 100644 --- a/tests/unit/audiobook-settings.vitest.spec.ts +++ b/tests/unit/audiobook-settings.vitest.spec.ts @@ -95,6 +95,7 @@ describe('coerceAudiobookGenerationSettings', () => { postSpeed: 1, format: 'mp3', ttsInstructions: '', + language: 'en', }, restrictUserApiKeys: true, fallbackProviderRef: 'shared-openai', @@ -113,5 +114,6 @@ describe('coerceAudiobookGenerationSettings', () => { expect(settings.providerType).toBe('openai'); expect(settings.ttsModel).toBe('gpt-4o-mini-tts'); expect(settings.ttsInstructions).toBe('Speak with warmth.'); + expect(settings.language).toBe('en'); }); });