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.
This commit is contained in:
Richard R 2026-06-06 12:19:28 -06:00
parent 4b226858b1
commit 379e5082f0
10 changed files with 25 additions and 17 deletions

View file

@ -10,7 +10,7 @@
# 📄🔊 OpenReader # 📄🔊 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**. > Previously named **OpenReader-WebUI**.

View file

@ -4,7 +4,7 @@ title: Introduction
slug: / 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**. > Previously named **OpenReader-WebUI**.

View file

@ -18,6 +18,7 @@ import type {
TTSSegmentsManifestResponse, TTSSegmentsManifestResponse,
} from '@/types/client'; } from '@/types/client';
import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog'; import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { normalizeLanguageTag } from '@/lib/shared/language';
import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy'; import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
import { createRequestLogger } from '@/lib/server/logger'; 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 nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
const instructionsSource = rec.ttsInstructions ?? rec.instructions; const instructionsSource = rec.ttsInstructions ?? rec.instructions;
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : ''; 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; if (!providerRef || !providerType || !ttsModel || !voice) return null;
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions, language }; return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions, language };

View file

@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons'; 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 { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { import {
Badge, Badge,
@ -327,9 +327,9 @@ export function AdminProvidersPanel() {
? 'custom' ? 'custom'
: modelDefinitions[0]?.id ?? ''; : modelDefinitions[0]?.id ?? '';
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId); 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, providerRef: form.slug,
providerType: form.providerType, providerType,
model, model,
}).supportsInstructions, [form.slug, form.providerType]); }).supportsInstructions, [form.slug, form.providerType]);
const baseUrlPlaceholder = form.providerType === 'custom-openai' const baseUrlPlaceholder = form.providerType === 'custom-openai'
@ -425,7 +425,7 @@ export function AdminProvidersPanel() {
providerType: opt.value, providerType: opt.value,
baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '', baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '',
defaultModel: nextModel, defaultModel: nextModel,
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '', defaultInstructions: modelSupportsInstructions(nextModel, opt.value) ? form.defaultInstructions : '',
}); });
setCustomModelInput(''); setCustomModelInput('');
}} }}

View file

@ -188,6 +188,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
activeReaderType, activeReaderType,
clearSegmentCaches, clearSegmentCaches,
resolvedLanguage, resolvedLanguage,
setDocumentLanguage,
} = useTTS(); } = useTTS();
const { const {
providerRef, providerRef,
@ -421,7 +422,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
updateConfigKey('voiceSpeed', Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1), updateConfigKey('voiceSpeed', Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1),
updateConfigKey('ttsInstructions', settings.ttsInstructions || ''), updateConfigKey('ttsInstructions', settings.ttsInstructions || ''),
]); ]);
}, [updateConfigKey]); if (settings.language) setDocumentLanguage(settings.language);
}, [updateConfigKey, setDocumentLanguage]);
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
didAutoScrollOnOpenRef.current = false; didAutoScrollOnOpenRef.current = false;

View file

@ -31,19 +31,22 @@ export function useDocumentLanguage(documentId: string | undefined): {
const updateLanguage = useCallback(async (language: string): Promise<void> => { const updateLanguage = useCallback(async (language: string): Promise<void> => {
if (!documentId) return; if (!documentId) return;
const next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, { let next = DEFAULT_DOCUMENT_SETTINGS;
...settings, setSettings((prev) => {
next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
...prev,
schemaVersion: 1, schemaVersion: 1,
language, language,
}); });
setSettings(next); return next;
});
try { try {
const response = await putDocumentSettings(documentId, next); const response = await putDocumentSettings(documentId, next);
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings)); setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
} catch (error) { } catch (error) {
console.warn('Failed to persist document language:', error); console.warn('Failed to persist document language:', error);
} }
}, [documentId, settings]); }, [documentId]);
return { return {
language: settings.language ?? 'auto', language: settings.language ?? 'auto',

View file

@ -644,7 +644,7 @@ async function runProviderRequest(
} }
if (request.provider !== 'openai' && request.language) { 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)) { if (!openAiCompatibleLanguageUnsupported.has(supportKey)) {
try { try {
return await fetchTTSBufferWithRetry( return await fetchTTSBufferWithRetry(

View file

@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({
model, model,
apiKey = '', apiKey = '',
}: ResolveVoicesOptions): Promise<ReplicateLanguageInput | null> { }: ResolveVoicesOptions): Promise<ReplicateLanguageInput | null> {
if (provider !== 'replicate' || !apiKey) return null; if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) return null;
const cached = replicateLanguageInputCache.get(model); const cached = replicateLanguageInputCache.get(model);
if (cached) return cached; if (cached) return cached;

View file

@ -2,7 +2,7 @@ English
OpenReader should split this sentence correctly. This is the second sentence. OpenReader should split this sentence correctly. This is the second sentence.
French French
OpenReader doit correctement segmenter cette phrase. Voici la deuxieme phrase. OpenReader doit correctement segmenter cette phrase. Voici la deuxième phrase.
Spanish Spanish
OpenReader debe segmentar correctamente esta frase. Esta es la segunda frase. OpenReader debe segmentar correctamente esta frase. Esta es la segunda frase.

View file

@ -95,6 +95,7 @@ describe('coerceAudiobookGenerationSettings', () => {
postSpeed: 1, postSpeed: 1,
format: 'mp3', format: 'mp3',
ttsInstructions: '', ttsInstructions: '',
language: 'en',
}, },
restrictUserApiKeys: true, restrictUserApiKeys: true,
fallbackProviderRef: 'shared-openai', fallbackProviderRef: 'shared-openai',
@ -113,5 +114,6 @@ describe('coerceAudiobookGenerationSettings', () => {
expect(settings.providerType).toBe('openai'); expect(settings.providerType).toBe('openai');
expect(settings.ttsModel).toBe('gpt-4o-mini-tts'); expect(settings.ttsModel).toBe('gpt-4o-mini-tts');
expect(settings.ttsInstructions).toBe('Speak with warmth.'); expect(settings.ttsInstructions).toBe('Speak with warmth.');
expect(settings.language).toBe('en');
}); });
}); });