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'); }); });