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:
parent
4b226858b1
commit
379e5082f0
10 changed files with 25 additions and 17 deletions
|
|
@ -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**.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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**.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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('');
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue