feat(tts): add multilingual reader foundation

This commit is contained in:
Richard R 2026-06-06 06:18:50 -06:00
parent 328dbbbb62
commit 07e27430c2
34 changed files with 590 additions and 132 deletions

View file

@ -19,6 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
import { useDocumentLanguage } from '@/hooks/useDocumentLanguage';
import { ButtonLink } from '@/components/ui';
import { useEpubDocument } from './useEpubDocument';
@ -37,7 +38,8 @@ export default function EPUBPage() {
regenerateChapter: regenerateEPUBChapter,
bookRef,
} = epubState;
const { stop } = useTTS();
const { stop, setDocumentLanguage } = useTTS();
const { language, updateLanguage } = useDocumentLanguage(routeDocumentId);
const { isAtLimit } = useAuthRateLimit();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
@ -106,6 +108,10 @@ export default function EPUBPage() {
useUnmountCleanupRef(clearCurrDoc);
useEffect(() => {
setDocumentLanguage(language);
}, [language, setDocumentLanguage]);
// Compute available height = viewport - (header height + tts bar height)
useEffect(() => {
const compute = () => {
@ -249,6 +255,10 @@ export default function EPUBPage() {
epub
isOpen={activeSidebar === 'settings'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
language={language}
onLanguageChange={(nextLanguage) => {
void updateLanguage(nextLanguage);
}}
/>
<SegmentsSidebar
isOpen={activeSidebar === 'segments'}

View file

@ -93,7 +93,16 @@ export interface EpubDocumentState {
* Route-local EPUB reader hook.
*/
export function useEpubDocument(documentId?: string): EpubDocumentState {
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS();
const {
setText: setTTSText,
currDocPage,
currDocPages,
setCurrDocPages,
stop,
skipToLocation,
setIsEPUB,
resolvedLanguage,
} = useTTS();
// Configuration context to get TTS settings
const {
apiKey,
@ -145,6 +154,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
currentWordHighlightCfiRef: currentWordHighlightCfi,
renderedTextMapsRef,
wordHighlightMapCacheRef,
language: resolvedLanguage,
});
useEffect(() => () => {

View file

@ -17,6 +17,7 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
import { useDocumentLanguage } from '@/hooks/useDocumentLanguage';
import { ButtonLink } from '@/components/ui';
import type { TTSAudiobookChapter } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
@ -26,6 +27,7 @@ export default function HTMLPage() {
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
const { id } = useParams();
const router = useRouter();
const routeDocumentId = typeof id === 'string' ? id : undefined;
const htmlState = useHtmlDocument();
const {
setCurrentDocument,
@ -38,7 +40,8 @@ export default function HTMLPage() {
createFullAudioBook,
regenerateChapter,
} = htmlState;
const { stop } = useTTS();
const { stop, setDocumentLanguage } = useTTS();
const { language, updateLanguage } = useDocumentLanguage(routeDocumentId);
const { isAtLimit } = useAuthRateLimit();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
@ -106,6 +109,10 @@ export default function HTMLPage() {
useUnmountCleanupRef(clearCurrDoc);
useEffect(() => {
setDocumentLanguage(language);
}, [language, setDocumentLanguage]);
// Compute available height = viewport - (header height + tts bar height)
useEffect(() => {
const compute = () => {
@ -240,6 +247,10 @@ export default function HTMLPage() {
html
isOpen={activeSidebar === 'settings'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
language={language}
onLanguageChange={(nextLanguage) => {
void updateLanguage(nextLanguage);
}}
/>
<SegmentsSidebar
isOpen={activeSidebar === 'segments'}

View file

@ -493,6 +493,14 @@ export default function PDFViewerPage() {
<DocumentSettings
isOpen={activeSidebar === 'settings'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
language={documentSettings.language ?? 'auto'}
onLanguageChange={(language) => {
void updateDocumentSettings({
...documentSettings,
schemaVersion: 1,
language,
});
}}
pdf={{
parseStatus,
parsedOverlayEnabled,

View file

@ -91,6 +91,7 @@ export interface PdfDocumentState {
parsedDocument?: ParsedPdfDocument | null;
locator?: TTSSegmentLocator | null;
useBlockGeometryOnly?: boolean;
language?: string;
},
) => void;
clearHighlights: () => void;
@ -134,6 +135,7 @@ export function usePdfDocument(): PdfDocumentState {
currDocPages,
setCurrDocPages,
setIsEPUB,
setDocumentLanguage,
registerVisualPageChangeHandler,
} = useTTS();
const {
@ -156,6 +158,10 @@ export function usePdfDocument(): PdfDocumentState {
const [parseProgress, setParseProgress] = useState<PdfParseProgress | null>(null);
const [, setActiveParseOpId] = useState<string | null>(null);
const [documentSettings, setDocumentSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
useEffect(() => {
setDocumentLanguage(documentSettings.language ?? 'auto');
lastPreparedPlaybackPageRef.current = null;
}, [documentSettings.language, setDocumentLanguage]);
const [parsedOverlayEnabled, setParsedOverlayEnabled] = useState(false);
const [isAudioCombining] = useState(false);
const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({

View file

@ -432,7 +432,8 @@ export async function POST(request: NextRequest) {
normalizedExistingSettings.nativeSpeed !== normalizedIncomingSettings.nativeSpeed ||
normalizedExistingSettings.postSpeed !== normalizedIncomingSettings.postSpeed ||
normalizedExistingSettings.format !== normalizedIncomingSettings.format ||
(normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || '');
(normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || '') ||
(normalizedExistingSettings.language || '') !== (normalizedIncomingSettings.language || '');
if (mismatch) {
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: normalizedExistingSettings }, { status: 409 });
}

View file

@ -33,6 +33,7 @@ import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tt
import { userWhisperAlignJob } from '@/lib/server/jobs/user-whisper-align-job';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy';
import { normalizeLanguageTag } from '@/lib/shared/language';
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
import { createRequestLogger, errorToLog } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
@ -62,6 +63,8 @@ function parseSettings(value: unknown): TTSSegmentSettings | null {
if (typeof rec.voice !== 'string') return null;
if (!Number.isFinite(Number(rec.nativeSpeed))) return null;
if (rec.ttsInstructions !== undefined && typeof rec.ttsInstructions !== 'string') return null;
if (rec.language !== undefined && typeof rec.language !== 'string') return null;
if (typeof rec.language === 'string' && rec.language.length > 64) return null;
return {
providerRef: rec.providerRef,
@ -70,6 +73,7 @@ function parseSettings(value: unknown): TTSSegmentSettings | null {
voice: rec.voice,
nativeSpeed: Number(rec.nativeSpeed),
...(typeof rec.ttsInstructions === 'string' ? { ttsInstructions: rec.ttsInstructions } : {}),
...(typeof rec.language === 'string' ? { language: normalizeLanguageTag(rec.language) } : {}),
};
}
@ -406,6 +410,7 @@ export async function POST(request: NextRequest) {
alignment = await userWhisperAlignJob({
audioObjectKey: existing.audioKey,
text: segment.text,
lang: effectiveSettings.language,
sentenceIndex: segment.original.segmentIndex,
});
stageTimings.selfHealAlignMs = Date.now() - alignStartedAt;
@ -674,6 +679,7 @@ export async function POST(request: NextRequest) {
alignment = await userWhisperAlignJob({
audioObjectKey: audioKey,
text: segment.text,
lang: effectiveSettings.language,
sentenceIndex: segment.original.segmentIndex,
});
stageTimings.whisperAlignMs = Date.now() - alignStartedAt;

View file

@ -71,9 +71,10 @@ 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';
if (!providerRef || !providerType || !ttsModel || !voice) return null;
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions };
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions, language };
}
function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {

View file

@ -12,6 +12,7 @@ import { useTTS } from '@/contexts/TTSContext';
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { resolveTtsLanguage } from '@/lib/shared/language';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui';
import {
@ -50,7 +51,7 @@ export function AudiobookExportModal({
onRegenerateChapter
}: AudiobookExportModalProps) {
const { isLoading, isDBReady, providerRef, providerType, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig();
const { availableVoices } = useTTS();
const { availableVoices, documentLanguage } = useTTS();
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const [isGenerating, setIsGenerating] = useState(false);
const [chapters, setChapters] = useState<TTSAudiobookChapter[]>([]);
@ -125,8 +126,12 @@ export function AudiobookExportModal({
postSpeed,
format,
ttsInstructions: providerModelPolicy.supportsInstructions ? ttsInstructions : undefined,
language: resolveTtsLanguage({
configuredLanguage: documentLanguage,
voice: nextVoice,
}),
};
}, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions]);
}, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions, documentLanguage]);
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
if (soft) {

View file

@ -50,6 +50,20 @@ const viewTypeTextMapping = [
{ id: 'scroll', name: 'Continuous Scroll' },
];
const DOCUMENT_LANGUAGE_OPTIONS = [
{ value: 'auto', label: 'Automatic (voice or metadata)' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'fr', label: 'French' },
{ value: 'hi', label: 'Hindi' },
{ value: 'it', label: 'Italian' },
{ value: 'ja', label: 'Japanese' },
{ value: 'pt-BR', label: 'Portuguese (Brazil)' },
{ value: 'zh-CN', label: 'Chinese (Simplified)' },
{ value: 'ar', label: 'Arabic' },
{ value: 'th', label: 'Thai' },
];
type RangeSettingProps = {
label: string;
value: number;
@ -92,11 +106,13 @@ function RangeSetting({
);
}
export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, onLanguageChange, pdf }: {
isOpen: boolean,
setIsOpen: (isOpen: boolean) => void,
epub?: boolean,
html?: boolean,
language?: string,
onLanguageChange?: (language: string) => void,
pdf?: {
parseStatus: PdfParseStatus | null;
parsedOverlayEnabled: boolean;
@ -151,6 +167,30 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
panelClassName="w-full sm:w-[30rem]"
>
<div className="space-y-4">
{language && onLanguageChange ? (
<Section
title="Language"
subtitle="Controls sentence splitting and synchronized word alignment."
variant="flat"
>
<label className="block space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted">
Document language
</span>
<select
value={language}
onChange={(event) => onLanguageChange(event.target.value)}
className="w-full rounded-md border border-offbase bg-surface-solid px-3 py-2 text-sm text-foreground"
>
{DOCUMENT_LANGUAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</Section>
) : null}
{isPdfMode && pdf && (
<Section
title="PDF Essentials"

View file

@ -35,6 +35,7 @@ export function HTMLViewer({
currentSentence,
currentSentenceAlignment,
currentWordIndex,
resolvedLanguage,
} = useTTS();
const { htmlHighlightEnabled, htmlWordHighlightEnabled } = useConfig();
@ -96,7 +97,7 @@ export function HTMLViewer({
// here (not in cleanup) avoids a Strict-Mode-induced wipe of the very
// first highlight.
clearHtmlSentenceHighlight();
const matched = highlightHtmlSentence(container, currentSentence);
const matched = highlightHtmlSentence(container, currentSentence, resolvedLanguage);
if (matched) {
scrollSentenceIntoView(scrollRef.current);
return;
@ -118,6 +119,7 @@ export function HTMLViewer({
}, [
htmlHighlightEnabled,
currentSentence,
resolvedLanguage,
blocks,
clearSentenceTimeouts,
scheduleSentence,

View file

@ -59,6 +59,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
currentSentenceAlignment,
currentSegment,
skipToLocation,
resolvedLanguage,
} = useTTS();
const {
@ -207,6 +208,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
parsedDocument,
locator: activeLocator,
useBlockGeometryOnly,
language: resolvedLanguage,
});
};
@ -224,6 +226,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
pdfHighlightEnabled,
pdfWordHighlightEnabled,
parsedDocument,
resolvedLanguage,
layoutKey,
isPageRendering,
clearSentenceHighlightTimeouts,

View file

@ -65,6 +65,7 @@ import {
} from '@/lib/client/epub/tts-epub-handoff';
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { resolveTtsLanguage } from '@/lib/shared/language';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
import type {
EpubRenderedLocationWalker,
@ -146,6 +147,9 @@ interface TTSContextType extends TTSPlaybackState {
setSpeedAndRestart: (speed: number) => void;
setAudioPlayerSpeedAndRestart: (speed: number) => void;
setVoiceAndRestart: (voice: string) => void;
documentLanguage: string;
resolvedLanguage: string;
setDocumentLanguage: (language: string) => void;
clearSegmentCaches: () => void;
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation
@ -234,8 +238,9 @@ const normalizeLocationKey = normalizeTtsLocationKey;
const normalizeBlockFingerprint = (text: string): string => {
const normalized = preprocessSentenceForAudio(text)
.normalize('NFKC')
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/[^\p{L}\p{N}\p{M}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
return normalized.slice(0, 200);
@ -249,6 +254,7 @@ const buildCacheKey = (
model: string,
providerType: string,
instructions: string,
language: string,
) => {
return [
`provider=${provider || ''}`,
@ -257,6 +263,7 @@ const buildCacheKey = (
`voice=${voice || ''}`,
`speed=${Number.isFinite(speed) ? speed : ''}`,
`instructions=${instructions || ''}`,
`language=${language || ''}`,
`text=${sentence}`,
].join('|');
};
@ -271,10 +278,11 @@ const buildScopedSegmentCacheKey = (
model: string,
providerType: string,
instructions: string,
language: string,
segmentKey?: string | null,
) => {
return [
buildCacheKey(sentence, voice, speed, provider, model, providerType, instructions),
buildCacheKey(sentence, voice, speed, provider, model, providerType, instructions, language),
`segmentKey=${segmentKey || ''}`,
`locator=${segmentKey ? '' : buildLocatorRequestKey(locator)}`,
`segmentIndex=${segmentKey ? '' : segmentIndex}`,
@ -567,6 +575,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
const [voice, setVoice] = useState(configVoice);
const [ttsModel, setTTSModel] = useState(configTTSModel);
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
const [documentLanguage, setDocumentLanguage] = useState('auto');
const resolvedLanguage = useMemo(
() => resolveTtsLanguage({ configuredLanguage: documentLanguage, voice }),
[documentLanguage, voice],
);
const providerModelPolicy = useMemo(
() => resolveTtsProviderModelPolicy({
providerRef: configProviderRef,
@ -1231,6 +1244,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
maxBlockLength: ttsSegmentMaxBlockLength,
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0,
language: resolvedLanguage,
});
const currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey));
const newSentences = currentSegments.map((segment) => segment.text);
@ -1383,6 +1397,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
currDocPage,
documentId,
ttsSegmentMaxBlockLength,
resolvedLanguage,
clearPendingEpubJump,
]);
@ -1496,6 +1511,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
voice,
effectiveNativeSpeed,
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
resolvedLanguage,
ttsSegmentMaxBlockLength,
].join('|');
@ -1516,6 +1532,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
effectiveNativeSpeed,
providerModelPolicy.supportsInstructions,
ttsInstructions,
resolvedLanguage,
ttsSegmentMaxBlockLength,
clearPendingEpubJump,
bumpEpubPreloadGeneration,
@ -1589,6 +1606,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel,
configProviderType,
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
resolvedLanguage,
segmentKey,
);
@ -1669,6 +1687,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
voice,
nativeSpeed: effectiveNativeSpeed,
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
language: resolvedLanguage,
},
segments: persistSegments,
}, reqHeaders, controller.signal),
@ -1772,6 +1791,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
effectiveNativeSpeed,
ttsModel,
ttsInstructions,
resolvedLanguage,
openApiKey,
openApiBaseUrl,
configProviderRef,
@ -2253,6 +2273,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel,
configProviderType,
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
resolvedLanguage,
playbackSegment?.key,
);
const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey);
@ -2290,6 +2311,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
configProviderType,
providerModelPolicy.supportsInstructions,
ttsInstructions,
resolvedLanguage,
isEPUB,
currDocPage,
currDocPageNumber,
@ -2388,6 +2410,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel,
configProviderType,
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
resolvedLanguage,
nextSegment?.key,
);
if (segmentManifestCacheRef.current.has(cacheKey)) {
@ -2498,6 +2521,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
readerType: 'epub',
maxBlockLength: ttsSegmentMaxBlockLength,
keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'),
language: resolvedLanguage,
});
const uniqueCandidates: Array<EpubLocationPreloadCandidate & { locator: TTSSegmentLocator }> = [];
const seenCandidates = new Set<string>();
@ -2520,6 +2544,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel,
configProviderType,
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
resolvedLanguage,
segment.key,
);
if (seenCandidates.has(requestKey)) continue;
@ -2564,6 +2589,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
voice,
nativeSpeed: effectiveNativeSpeed,
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
language: resolvedLanguage,
},
segments: persistPayload,
}, reqHeaders, controller.signal),
@ -2660,6 +2686,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel,
configProviderType,
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
resolvedLanguage,
plannedSegment?.key,
);
const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, plannedSegment?.key);
@ -2691,6 +2718,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
ttsModel,
configProviderType,
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
resolvedLanguage,
segment.key,
);
const requestKey = buildSegmentRequestKey(segmentLocator, index, sentence, segment.key);
@ -2755,6 +2783,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
voice,
nativeSpeed: effectiveNativeSpeed,
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
language: resolvedLanguage,
},
segments: persistPayload,
}, reqHeaders, controller.signal),
@ -2841,6 +2870,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
openApiBaseUrl,
providerModelPolicy.supportsInstructions,
ttsInstructions,
resolvedLanguage,
onTTSStart,
onTTSComplete,
processSentence,
@ -3145,6 +3175,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setSpeedAndRestart,
setAudioPlayerSpeedAndRestart,
setVoiceAndRestart,
documentLanguage,
resolvedLanguage,
setDocumentLanguage,
clearSegmentCaches,
skipToLocation,
registerLocationChangeHandler,
@ -3176,6 +3209,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
setSpeedAndRestart,
setAudioPlayerSpeedAndRestart,
setVoiceAndRestart,
documentLanguage,
resolvedLanguage,
clearSegmentCaches,
skipToLocation,
registerLocationChangeHandler,

View file

@ -30,6 +30,7 @@ type UseEpubHighlightingParams = {
currentWordHighlightCfiRef: MutableRefObject<string | null>;
renderedTextMapsRef: MutableRefObject<EpubRenderedTextMap[]>;
wordHighlightMapCacheRef: MutableRefObject<EpubWordHighlightMapCache | null>;
language?: string;
};
type UseEpubHighlightingResult = {
@ -52,6 +53,7 @@ export function useEPUBHighlighting({
currentWordHighlightCfiRef,
renderedTextMapsRef,
wordHighlightMapCacheRef,
language,
}: UseEpubHighlightingParams): UseEpubHighlightingResult {
const clearWordHighlights = useCallback(() => {
if (!renditionRef.current) return;
@ -116,9 +118,9 @@ export function useEPUBHighlighting({
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return;
const cacheKey = buildWordHighlightCacheKey(segment, alignment);
const cacheKey = buildWordHighlightCacheKey(segment, alignment, language);
if (wordHighlightMapCacheRef.current?.key !== cacheKey) {
const tokens = tokenizeCanonicalSegment(segment);
const tokens = tokenizeCanonicalSegment(segment, language);
wordHighlightMapCacheRef.current = {
key: cacheKey,
tokens,
@ -162,6 +164,7 @@ export function useEPUBHighlighting({
renderedTextMapsRef,
renditionRef,
wordHighlightMapCacheRef,
language,
]);
const setRenderedTextMaps = useCallback((maps: EpubRenderedTextMap[]) => {

View file

@ -0,0 +1,52 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { getDocumentSettings, putDocumentSettings } from '@/lib/client/api/documents';
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
export function useDocumentLanguage(documentId: string | undefined): {
language: string;
updateLanguage: (language: string) => Promise<void>;
} {
const [settings, setSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
useEffect(() => {
setSettings(DEFAULT_DOCUMENT_SETTINGS);
if (!documentId) return;
const controller = new AbortController();
void getDocumentSettings(documentId, { signal: controller.signal })
.then((response) => {
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
})
.catch((error) => {
if (error instanceof DOMException && error.name === 'AbortError') return;
console.warn('Failed to load document language, using automatic detection:', error);
});
return () => controller.abort();
}, [documentId]);
const updateLanguage = useCallback(async (language: string): Promise<void> => {
if (!documentId) return;
const next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
...settings,
schemaVersion: 1,
language,
});
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]);
return {
language: settings.language ?? 'auto',
updateLanguage,
};
}

View file

@ -12,6 +12,7 @@ import type {
TTSAudiobookChapter,
TTSAudiobookFormat,
} from '@/types/tts';
import { normalizeTextForTts } from '@/lib/shared/nlp';
export interface PreparedAudiobookChapter {
index: number;
@ -109,7 +110,10 @@ export async function runAudiobookGeneration({
maxDelay: 300,
},
}: RunAudiobookGenerationOptions): Promise<string> {
const chapters = await adapter.prepareChapters();
const chapters = (await adapter.prepareChapters()).map((chapter) => ({
...chapter,
text: normalizeTextForTts(chapter.text, { language: settings?.language }),
}));
const totalLength = chapters.reduce((sum, chapter) => sum + chapter.text.trim().length, 0);
if (totalLength === 0) {
throw new Error(adapter.noContentMessage);
@ -228,7 +232,7 @@ export async function regenerateAudiobookChapter({
},
}: RegenerateAudiobookChapterOptions): Promise<TTSAudiobookChapter> {
const chapter = await adapter.prepareChapter(chapterIndex);
const trimmedText = chapter.text.trim();
const trimmedText = normalizeTextForTts(chapter.text, { language: settings?.language }).trim();
if (!trimmedText) {
throw new Error(adapter.noContentMessage);
}

View file

@ -1,5 +1,6 @@
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
import type { TTSSentenceAlignment } from '@/types/tts';
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
export type EpubCanonicalWordToken = {
norm: string;
@ -8,35 +9,19 @@ export type EpubCanonicalWordToken = {
};
export const normalizeWordForHighlight = (text: string): string =>
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
normalizeUnicodeToken(text);
export const tokenizeCanonicalSegment = (segment: CanonicalTtsSegment): EpubCanonicalWordToken[] => {
const tokens: EpubCanonicalWordToken[] = [];
const wordRegex = /\S+/g;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(segment.text)) !== null) {
const raw = match[0];
const leading = raw.match(/^[^A-Za-z0-9]*/)?.[0].length ?? 0;
const trailing = raw.match(/[^A-Za-z0-9]*$/)?.[0].length ?? 0;
const start = match.index + leading;
const end = match.index + raw.length - trailing;
if (end <= start) continue;
const norm = normalizeWordForHighlight(raw.slice(leading, raw.length - trailing));
if (!norm) continue;
tokens.push({
norm,
sourceStart: segment.startAnchor.offset + start,
sourceEnd: segment.startAnchor.offset + end,
});
}
return tokens;
};
export const tokenizeCanonicalSegment = (
segment: CanonicalTtsSegment,
language?: string,
): EpubCanonicalWordToken[] =>
segmentWords(segment.text, language)
.map((token) => ({
norm: normalizeWordForHighlight(token.text),
sourceStart: segment.startAnchor.offset + token.start,
sourceEnd: segment.startAnchor.offset + token.end,
}))
.filter((token) => Boolean(token.norm));
export const buildMonotonicWordToTokenMap = (
alignmentWords: TTSSentenceAlignment['words'],
@ -105,10 +90,12 @@ export const buildMonotonicWordToTokenMap = (
export const buildWordHighlightCacheKey = (
segment: CanonicalTtsSegment,
alignment: TTSSentenceAlignment,
language?: string,
): string =>
[
segment.key,
segment.text.length,
language || '',
alignment.words.length,
alignment.words.map((word) => normalizeWordForHighlight(word.text)).join('|'),
].join('::');

View file

@ -18,6 +18,7 @@
*/
import { CmpStr } from 'cmpstr';
import type { TTSSentenceAlignment } from '@/types/tts';
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
export const HTML_SENTENCE_CLASS = 'openreader-html-highlight-sentence';
export const HTML_WORD_CLASS = 'openreader-html-highlight-word';
@ -53,21 +54,11 @@ interface SentenceState {
let sentenceState: SentenceState | null = null;
function normalizeWord(word: string): string {
return word
.toLowerCase()
.replace(/[\p{P}\p{S}]+/gu, '')
.trim();
return normalizeUnicodeToken(word);
}
function tokenizePattern(pattern: string): string[] {
const out: string[] = [];
const wordRe = /\S+/g;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(pattern)) !== null) {
const norm = normalizeWord(m[0]);
if (norm) out.push(norm);
}
return out;
function tokenizePattern(pattern: string, language?: string): string[] {
return segmentWords(pattern, language).map((token) => normalizeWord(token.text)).filter(Boolean);
}
function unwrap(span: HTMLSpanElement): void {
@ -108,7 +99,11 @@ function isHighlightWrapper(node: Node | null): node is HTMLSpanElement {
return el.classList.contains(HTML_SENTENCE_CLASS) || el.classList.contains(HTML_WORD_CLASS);
}
function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean } = { skipHighlightWraps: true }): DomToken[] {
function collectDomTokens(
root: HTMLElement,
language?: string,
opts: { skipHighlightWraps: boolean } = { skipHighlightWraps: true },
): DomToken[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
@ -131,15 +126,13 @@ function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean
while (current) {
const textNode = current as Text;
const text = textNode.nodeValue || '';
const wordRe = /\S+/g;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(text)) !== null) {
const norm = normalizeWord(m[0]);
for (const token of segmentWords(text, language)) {
const norm = normalizeWord(token.text);
if (!norm) continue;
tokens.push({
textNode,
startOffset: m.index,
endOffset: m.index + m[0].length,
startOffset: token.start,
endOffset: token.end,
norm,
});
}
@ -153,7 +146,7 @@ function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean
* wrap is applied; lets us index just the words *within* the highlighted
* sentence rather than the whole document).
*/
function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
function collectTokensInsideWraps(wraps: HTMLSpanElement[], language?: string): DomToken[] {
const tokens: DomToken[] = [];
for (const wrap of wraps) {
const walker = document.createTreeWalker(wrap, NodeFilter.SHOW_TEXT);
@ -161,15 +154,13 @@ function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
while (current) {
const t = current as Text;
const text = t.nodeValue || '';
const wordRe = /\S+/g;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(text)) !== null) {
const norm = normalizeWord(m[0]);
for (const token of segmentWords(text, language)) {
const norm = normalizeWord(token.text);
if (!norm) continue;
tokens.push({
textNode: t,
startOffset: m.index,
endOffset: m.index + m[0].length,
startOffset: token.start,
endOffset: token.end,
norm,
});
}
@ -260,14 +251,15 @@ function wrapTokenRange(tokens: DomToken[], start: number, end: number, classNam
export function highlightHtmlSentence(
container: HTMLElement | null | undefined,
sentence: string | null | undefined,
language?: string,
): boolean {
clearHtmlSentenceHighlight();
if (!container || !sentence?.trim()) return false;
const patternTokens = tokenizePattern(sentence);
const patternTokens = tokenizePattern(sentence, language);
if (!patternTokens.length) return false;
const domTokens = collectDomTokens(container);
const domTokens = collectDomTokens(container, language);
if (!domTokens.length) return false;
const win = findBestWindow(domTokens, patternTokens);
@ -280,7 +272,7 @@ export function highlightHtmlSentence(
// can look up individual word tokens without re-walking the doc.
sentenceState = {
sentence,
wordTokens: collectTokensInsideWraps(sentenceWraps),
wordTokens: collectTokensInsideWraps(sentenceWraps, language),
alignment: null,
wordToToken: null,
};

View file

@ -5,6 +5,7 @@ import type { TTSSentenceAlignment } from '@/types/tts';
import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf';
import { CmpStr } from 'cmpstr';
import type { TTSSegmentLocator } from '@/types/client';
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
@ -181,10 +182,7 @@ function getOrCreateHighlightLayer(span: HTMLElement): {
}
const normalizeWordForMatch = (text: string): string =>
text
.trim()
.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '')
.toLowerCase();
normalizeUnicodeToken(text);
// Highlighting functions
let highlightPatternSeq = 0;
@ -193,6 +191,7 @@ type HighlightPatternOptions = {
parsedDocument?: ParsedPdfDocument | null;
locator?: TTSSegmentLocator | null;
useBlockGeometryOnly?: boolean;
language?: string;
};
function getHighlightLayerForPage(pageElement: HTMLElement): {
@ -451,17 +450,13 @@ export function highlightPattern(
const textNode = node as Text;
const textContent = textNode.textContent || '';
const wordRegex = /\S+/g;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(textContent)) !== null) {
const word = match[0];
for (const token of segmentWords(textContent, options?.language)) {
tokens.push({
spanIndex,
textNode,
text: word,
startOffset: match.index,
endOffset: match.index + word.length,
text: token.text,
startOffset: token.start,
endOffset: token.end,
});
}
});

View file

@ -9,6 +9,7 @@ import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat } from '@/types/tts';
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
import { resolvePreferredSharedProviderSlug } from '@/lib/shared/shared-provider-selection';
import { normalizeLanguageTag } from '@/lib/shared/language';
function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat {
return value === 'mp3' || value === 'm4b';
@ -68,6 +69,7 @@ export function coerceAudiobookGenerationSettings(
postSpeed,
format,
...(typeof record.ttsInstructions === 'string' ? { ttsInstructions: record.ttsInstructions } : {}),
...(typeof record.language === 'string' ? { language: normalizeLanguageTag(record.language) } : {}),
};
const migrated =

View file

@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
import { locatorIdentityKey } from '@/lib/shared/tts-locator';
import { ffprobeAudio } from '@/lib/server/audiobooks/chapters';
import type {
@ -32,6 +33,7 @@ function settingsCanonical(settings: TTSSegmentSettings): string {
voice: settings.voice,
speed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
instructions: settings.ttsInstructions || '',
language: settings.language || 'en',
format: 'mp3',
});
}
@ -48,6 +50,7 @@ export function buildTtsSegmentSettingsJson(settings: TTSSegmentSettings): TTSSe
voice: settings.voice,
nativeSpeed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
ttsInstructions: settings.ttsInstructions || '',
language: settings.language || 'en',
};
// Postgres jsonb accepts the object directly; SQLite text needs a canonical JSON string.
return process.env.POSTGRES_URL ? canonical : settingsCanonical(settings);
@ -242,35 +245,24 @@ export async function probeAudioDurationMsFromBuffer(buffer: Buffer, signal?: Ab
}
}
function alignWordsToText(sentence: string): Array<{ text: string; charStart: number; charEnd: number }> {
const words = sentence.match(/\S+/g) || [];
const aligned: Array<{ text: string; charStart: number; charEnd: number }> = [];
let cursor = 0;
const lowerSentence = sentence.toLowerCase();
for (const token of words) {
const clean = token.trim();
if (!clean) continue;
const idx = lowerSentence.indexOf(clean.toLowerCase(), cursor);
const start = idx >= 0 ? idx : cursor;
const end = Math.min(sentence.length, start + clean.length);
cursor = Math.max(cursor, end);
aligned.push({
text: clean,
charStart: start,
charEnd: end,
});
}
return aligned;
function alignWordsToText(
sentence: string,
language?: string,
): Array<{ text: string; charStart: number; charEnd: number }> {
return segmentWords(sentence, language).map((token) => ({
text: token.text,
charStart: token.start,
charEnd: token.end,
}));
}
export function buildProportionalAlignment(input: {
sentence: string;
sentenceIndex: number;
durationMs: number;
language?: string;
}): TTSSentenceAlignment {
const wordsWithOffsets = alignWordsToText(input.sentence);
const wordsWithOffsets = alignWordsToText(input.sentence, input.language);
if (wordsWithOffsets.length === 0 || input.durationMs <= 0) {
return {
sentence: input.sentence,
@ -281,7 +273,7 @@ export function buildProportionalAlignment(input: {
const weighted = wordsWithOffsets.map((word) => ({
...word,
weight: Math.max(1, word.text.replace(/[^a-zA-Z0-9]/g, '').length),
weight: Math.max(1, normalizeUnicodeToken(word.text).length),
}));
const totalWeight = weighted.reduce((sum, word) => sum + word.weight, 0);

View file

@ -3,6 +3,7 @@ import {
type DocumentSettings,
} from '@/types/document-settings';
import { PARSED_PDF_BLOCK_KINDS, type ParsedPdfBlockKind } from '@/types/parsed-pdf';
import { normalizeLanguageTag } from '@/lib/shared/language';
function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] {
if (!Array.isArray(value)) return [...(DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? [])];
@ -22,6 +23,7 @@ export function mergeDocumentSettings(
): DocumentSettings {
const base: DocumentSettings = {
schemaVersion: 1,
language: defaults.language || 'auto',
pdf: {
skipBlockKinds: [...(defaults.pdf?.skipBlockKinds ?? [])],
},
@ -29,12 +31,17 @@ export function mergeDocumentSettings(
if (!stored || typeof stored !== 'object') return base;
const rec = stored as Record<string, unknown>;
const rawLanguage = typeof rec.language === 'string' ? rec.language.trim() : '';
const language = !rawLanguage || rawLanguage.toLowerCase() === 'auto'
? 'auto'
: normalizeLanguageTag(rawLanguage, defaults.language || 'en');
const pdf = rec.pdf;
if (!pdf || typeof pdf !== 'object') return base;
if (!pdf || typeof pdf !== 'object') return { ...base, language };
const pdfRec = pdf as Record<string, unknown>;
return {
schemaVersion: 1,
language,
pdf: {
skipBlockKinds: normalizeSkipKinds(pdfRec.skipBlockKinds),
},

119
src/lib/shared/language.ts Normal file
View file

@ -0,0 +1,119 @@
export const DEFAULT_TTS_LANGUAGE = 'en';
export interface TextToken {
text: string;
start: number;
end: number;
}
const KOKORO_LANGUAGE_BY_PREFIX: Readonly<Record<string, string>> = {
af: 'en-US',
am: 'en-US',
bf: 'en-GB',
bm: 'en-GB',
ef: 'es',
em: 'es',
ff: 'fr',
hf: 'hi',
hm: 'hi',
if: 'it',
im: 'it',
jf: 'ja',
jm: 'ja',
pf: 'pt-BR',
pm: 'pt-BR',
zf: 'zh-CN',
zm: 'zh-CN',
};
export function normalizeLanguageTag(
language: string | null | undefined,
fallback = DEFAULT_TTS_LANGUAGE,
): string {
const candidate = language?.trim();
if (!candidate || candidate.toLowerCase() === 'auto') return fallback;
try {
return Intl.getCanonicalLocales(candidate)[0] ?? fallback;
} catch {
return fallback;
}
}
export function toBaseLanguageCode(language: string | null | undefined): string {
const normalized = normalizeLanguageTag(language);
try {
return new Intl.Locale(normalized).language;
} catch {
return normalized.split('-')[0]?.toLowerCase() || DEFAULT_TTS_LANGUAGE;
}
}
export function inferKokoroLanguageFromVoice(voice: string | null | undefined): string | null {
if (!voice?.trim()) return null;
const languages = new Set(
voice
.split('+')
.map((part) => part.trim().replace(/\([^)]*\)/g, ''))
.map((name) => KOKORO_LANGUAGE_BY_PREFIX[name.slice(0, 2).toLowerCase()])
.filter((language): language is string => Boolean(language)),
);
return languages.size === 1 ? [...languages][0] : null;
}
export function resolveTtsLanguage(input: {
configuredLanguage?: string | null;
voice?: string | null;
}): string {
const configured = input.configuredLanguage?.trim();
if (configured && configured.toLowerCase() !== 'auto') {
return normalizeLanguageTag(configured);
}
return inferKokoroLanguageFromVoice(input.voice) ?? DEFAULT_TTS_LANGUAGE;
}
export function normalizeUnicodeToken(text: string): string {
return text
.normalize('NFKC')
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '');
}
export function segmentSentences(text: string, language?: string | null): string[] {
const normalizedLanguage = normalizeLanguageTag(language);
try {
return [...new Intl.Segmenter(normalizedLanguage, { granularity: 'sentence' }).segment(text)]
.map(({ segment }) => segment.trim())
.filter(Boolean);
} catch {
return text.split(/(?<=[.!?؟])\s*/u).map((segment) => segment.trim()).filter(Boolean);
}
}
export function segmentWords(text: string, language?: string | null): TextToken[] {
const normalizedLanguage = normalizeLanguageTag(language);
try {
return [...new Intl.Segmenter(normalizedLanguage, { granularity: 'word' }).segment(text)]
.filter((segment) => segment.isWordLike)
.map((segment) => ({
text: segment.segment,
start: segment.index,
end: segment.index + segment.segment.length,
}));
} catch {
const tokens: TextToken[] = [];
const wordRegex = /\S+/gu;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(text)) !== null) {
tokens.push({
text: match[0],
start: match.index,
end: match.index + match[0].length,
});
}
return tokens;
}
}

View file

@ -5,13 +5,14 @@
* It handles text preprocessing, sentence splitting, and block creation for optimal TTS processing.
*/
import nlp from 'compromise';
import { normalizeLanguageTag, segmentSentences, toBaseLanguageCode } from '@/lib/shared/language';
export const MAX_BLOCK_LENGTH = 450;
const MIN_BLOCK_LENGTH = 50;
export interface TtsSplitOptions {
maxBlockLength?: number;
language?: string;
}
function resolveMaxBlockLength(options?: TtsSplitOptions): number {
@ -27,9 +28,9 @@ const splitOversizedText = (text: string, maxLen: number): string[] => {
const parts: string[] = [];
const MAX_OVERFLOW = maxLen; // allow finishing the sentence up to +maxLen chars
const CLOSERS = new Set(['"', "'", '”', '', ')', ']', '}']);
const BREAK_CHARS = new Set(['.', '!', '?']);
const SOFT_BREAK_CHARS = new Set([';', ':']);
const CLOSERS = new Set(['"', "'", '”', '', ')', ']', '}', '」', '』', '】']);
const BREAK_CHARS = new Set(['.', '!', '?', '。', '', '', '؟', '।']);
const SOFT_BREAK_CHARS = new Set([';', ':', '', '']);
const findPunctuationCut = (s: string, limit: number): number | null => {
for (let i = limit; i >= 0; i--) {
@ -48,7 +49,7 @@ const splitOversizedText = (text: string, maxLen: number): string[] => {
// Allow a boundary at end/whitespace, or common PDF artifact where
// the next sentence starts immediately with an uppercase letter.
if (!after || /\s/.test(after) || /[A-Z]/.test(after)) return end;
if (!after || /\s/u.test(after) || /\p{Lu}/u.test(after)) return end;
}
return null;
};
@ -74,7 +75,7 @@ const splitOversizedText = (text: string, maxLen: number): string[] => {
while (cut < s.length && CLOSERS.has(s[cut])) cut++;
const after = cut < s.length ? s[cut] : '';
if (!after || /\s/.test(after) || /[A-Z]/.test(after)) return cut;
if (!after || /\s/u.test(after) || /\p{Lu}/u.test(after)) return cut;
}
return null;
};
@ -87,7 +88,7 @@ const splitOversizedText = (text: string, maxLen: number): string[] => {
let end = i + 1;
while (end < s.length && CLOSERS.has(s[end])) end++;
const after = end < s.length ? s[end] : '';
if (!after || /\s/.test(after) || /[A-Z]/.test(after)) return end;
if (!after || /\s/u.test(after) || /\p{Lu}/u.test(after)) return end;
}
return null;
};
@ -123,8 +124,8 @@ const normalizeSentenceBoundariesForNlp = (text: string): string => {
// Insert a space only when it looks like a sentence boundary (lower/digit before,
// uppercase after) to avoid breaking abbreviations like "U.S.A".
return text
.replace(/([a-z0-9])([.!?])(?=[A-Z])/g, '$1$2 ')
.replace(/([a-z0-9][.!?][\"”’)\]])(?=[A-Z])/g, '$1 ');
.replace(/([\p{Ll}\p{N}])([.!?])(?=\p{Lu})/gu, '$1$2 ')
.replace(/([\p{Ll}\p{N}][.!?][")\]])(?=\p{Lu})/gu, '$1 ');
};
/**
@ -136,7 +137,7 @@ const normalizeSentenceBoundariesForNlp = (text: string): string => {
export const preprocessSentenceForAudio = (text: string): string => {
return text
.replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -')
.replace(/(\w+)-\s+(\w+)/g, '$1$2') // Remove hyphenation
.replace(/([\p{L}\p{N}\p{M}]+)-\s+([\p{L}\p{N}\p{M}]+)/gu, '$1$2') // Remove hyphenation
// Remove special character *
.replace(/\*/g, '')
.replace(/\s+/g, ' ')
@ -151,6 +152,8 @@ export const preprocessSentenceForAudio = (text: string): string => {
*/
export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): string[] => {
const maxBlockLength = resolveMaxBlockLength(options);
const language = normalizeLanguageTag(options?.language);
const blockSeparator = ['ja', 'zh'].includes(toBaseLanguageCode(language)) ? '' : ' ';
// Treat double-newlines as paragraph boundaries; single newlines are usually
// just PDF line wrapping and should not force sentence/block boundaries.
const paragraphs = text.split(/\n{2,}/);
@ -162,8 +165,7 @@ export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): s
const cleanedText = normalizeSentenceBoundariesForNlp(
preprocessSentenceForAudio(paragraph)
);
const doc = nlp(cleanedText);
const rawSentences = doc.sentences().out('array') as string[];
const rawSentences = segmentSentences(cleanedText, language);
// Merge multi-sentence dialogue enclosed in quotes into single items
const mergedSentences = mergeQuotedDialogue(rawSentences);
@ -179,8 +181,8 @@ export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): s
blocks.push(currentBlock.trim());
currentBlock = sentencePart;
} else {
currentBlock = currentBlock
? `${currentBlock} ${sentencePart}`
currentBlock = currentBlock
? `${currentBlock}${blockSeparator}${sentencePart}`
: sentencePart;
}
}
@ -200,6 +202,8 @@ export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): s
*/
export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions): string[] => {
const maxBlockLength = resolveMaxBlockLength(options);
const language = normalizeLanguageTag(options?.language);
const blockSeparator = ['ja', 'zh'].includes(toBaseLanguageCode(language)) ? '' : ' ';
const paragraphs = text.split(/\n+/);
const blocks: string[] = [];
@ -207,8 +211,7 @@ export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions
if (!paragraph.trim()) continue;
const cleanedText = preprocessSentenceForAudio(paragraph);
const doc = nlp(cleanedText);
const rawSentences = doc.sentences().out('array') as string[];
const rawSentences = segmentSentences(cleanedText, language);
const mergedSentences = mergeQuotedDialogue(rawSentences);
@ -227,7 +230,7 @@ export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions
currentBlock = sentencePart;
} else {
currentBlock = currentBlock
? `${currentBlock} ${sentencePart}`
? `${currentBlock}${blockSeparator}${sentencePart}`
: sentencePart;
}
}
@ -248,8 +251,11 @@ export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions
* @param {string} text - The text to process
* @returns {string} Normalized text
*/
export const normalizeTextForTts = (text: string, options?: TtsSplitOptions): string =>
splitTextToTtsBlocks(text, options).join(' ');
export const normalizeTextForTts = (text: string, options?: TtsSplitOptions): string => {
const language = normalizeLanguageTag(options?.language);
const separator = ['ja', 'zh'].includes(toBaseLanguageCode(language)) ? '' : ' ';
return splitTextToTtsBlocks(text, options).join(separator);
};
// Helper functions to merge quoted dialogue across sentences
const countDoubleQuotes = (s: string): number => {
@ -265,8 +271,8 @@ const countNonApostropheSingleQuotes = (s: string): number => {
if (ch === "'" || ch === '' || ch === '') {
const prev = i > 0 ? s[i - 1] : '';
const next = i + 1 < s.length ? s[i + 1] : '';
const isPrevAlphaNum = /[A-Za-z0-9]/.test(prev);
const isNextAlphaNum = /[A-Za-z0-9]/.test(next);
const isPrevAlphaNum = /[\p{L}\p{N}\p{M}]/u.test(prev);
const isNextAlphaNum = /[\p{L}\p{N}\p{M}]/u.test(next);
// Treat as a real quote mark only when it's not clearly an apostrophe
// between two alphanumeric characters (e.g., don't, WizardLMs).
if (!(isPrevAlphaNum && isNextAlphaNum)) {

View file

@ -2,7 +2,7 @@ import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksE
import type { TTSSegmentLocator } from '@/types/client';
import type { ReaderType } from '@/types/user-state';
export const TTS_SEGMENT_PLAN_VERSION = 'tts-segment-plan-v1';
export const TTS_SEGMENT_PLAN_VERSION = 'tts-segment-plan-v2';
export interface CanonicalTtsSourceUnit {
sourceKey: string;
@ -38,6 +38,7 @@ export interface CanonicalTtsSegmentPlanOptions {
maxBlockLength?: number;
keyPrefix?: string;
enforceSourceBoundaries?: boolean;
language?: string;
}
interface PreparedSourceUnit {
@ -215,7 +216,10 @@ export function planCanonicalTtsSegments(
}
const canonicalText = textParts.join('');
const splitOptions = { maxBlockLength: options.maxBlockLength };
const splitOptions = {
maxBlockLength: options.maxBlockLength,
language: options.language,
};
const splitIntoBlocks = (text: string): string[] =>
readerType === 'epub'
? splitTextToTtsBlocksEPUB(text, splitOptions)

View file

@ -45,6 +45,7 @@ export interface AudiobookGenerationSettings {
postSpeed: number;
format: TTSAudiobookFormat;
ttsInstructions?: string;
language?: string;
}
export interface CreateChapterPayload {
@ -70,6 +71,7 @@ export interface TTSSegmentSettings {
voice: string;
nativeSpeed: number;
ttsInstructions?: string;
language?: string;
}
type TTSReaderType = 'pdf' | 'epub' | 'html';

View file

@ -2,6 +2,7 @@ import type { ParsedPdfBlockKind } from '@/types/parsed-pdf';
export interface DocumentSettings {
schemaVersion: 1;
language?: string;
pdf?: {
skipBlockKinds: ParsedPdfBlockKind[];
};
@ -9,6 +10,7 @@ export interface DocumentSettings {
export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
schemaVersion: 1,
language: 'auto',
pdf: {
skipBlockKinds: ['header', 'footer', 'footnote', 'vision_footnote'],
},

View file

@ -0,0 +1,23 @@
English
OpenReader should split this sentence correctly. This is the second sentence.
French
OpenReader doit correctement segmenter cette phrase. Voici la deuxieme phrase.
Spanish
OpenReader debe segmentar correctamente esta frase. Esta es la segunda frase.
Arabic
يجب أن يقسم أوبن ريدر هذه الجملة بشكل صحيح. هذه هي الجملة الثانية.
Hindi
ओपनरीडर को इस वाक्य को सही ढंग से विभाजित करना चाहिए। यह दूसरा वाक्य है।
Japanese
OpenReaderはこの文を正しく分割する必要があります。これは二番目の文です。
Chinese
OpenReader应该正确地分割这个句子。这是第二句话。
Thai
OpenReader ควรแบ่งประโยคนี้อย่างถูกต้อง นี่คือประโยคที่สอง

View file

@ -15,6 +15,7 @@ describe('coerceAudiobookGenerationSettings', () => {
postSpeed: 1,
format: 'mp3',
ttsInstructions: 'keep calm',
language: 'ja-jp',
});
expect(result.migrated).toBe(false);
@ -27,6 +28,7 @@ describe('coerceAudiobookGenerationSettings', () => {
postSpeed: 1,
format: 'mp3',
ttsInstructions: 'keep calm',
language: 'ja-JP',
});
});

View file

@ -0,0 +1,22 @@
import { describe, expect, test } from 'vitest';
import { mergeDocumentSettings } from '../../src/lib/shared/document-settings';
import { DEFAULT_DOCUMENT_SETTINGS } from '../../src/types/document-settings';
describe('document settings language', () => {
test('defaults to automatic language resolution', () => {
expect(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, null).language).toBe('auto');
});
test('normalizes explicit BCP 47 language tags', () => {
expect(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, { language: 'zh-cn' }).language).toBe('zh-CN');
expect(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, { language: 'JA' }).language).toBe('ja');
});
test('preserves language when PDF settings are absent', () => {
expect(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, { language: 'fr' })).toMatchObject({
language: 'fr',
pdf: DEFAULT_DOCUMENT_SETTINGS.pdf,
});
});
});

View file

@ -28,6 +28,16 @@ const alignmentWords = (words: string[]): TTSSentenceAlignment['words'] =>
}));
describe('EPUB word highlight mapping', () => {
test('tokenizes Japanese and Chinese using locale-aware word boundaries', () => {
const japanese = tokenizeCanonicalSegment(segment('これは日本語です。', 5), 'ja');
expect(japanese.length).toBeGreaterThan(1);
expect(japanese.every((token) => token.norm.length > 0)).toBe(true);
const chinese = tokenizeCanonicalSegment(segment('这是中文。', 10), 'zh');
expect(chinese.length).toBeGreaterThan(1);
expect(chinese.map((token) => token.norm).join('')).toBe('这是中文');
});
test('tokenizes canonical segment words with source offsets', () => {
const tokens = tokenizeCanonicalSegment(segment('"Hello," she said.', 12));

View file

@ -0,0 +1,60 @@
import { describe, expect, test } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
inferKokoroLanguageFromVoice,
normalizeUnicodeToken,
resolveTtsLanguage,
segmentSentences,
segmentWords,
toBaseLanguageCode,
} from '../../src/lib/shared/language';
describe('multilingual language utilities', () => {
test('keeps the multilingual document fixture readable as UTF-8', () => {
const fixture = readFileSync(resolve(process.cwd(), 'tests/files/multilingual-sample.txt'), 'utf8');
expect(fixture).toContain('これは二番目の文です。');
expect(fixture).toContain('这是第二句话。');
expect(fixture).toContain('هذه هي الجملة الثانية.');
});
test('infers Kokoro language from a single-language voice selection', () => {
expect(inferKokoroLanguageFromVoice('jf_alpha')).toBe('ja');
expect(inferKokoroLanguageFromVoice('zf_xiaobei(0.5)+zm_yunxi(0.5)')).toBe('zh-CN');
expect(inferKokoroLanguageFromVoice('ff_siwis+jf_alpha')).toBeNull();
});
test('prefers an explicit language and resolves regional tags for Whisper', () => {
expect(resolveTtsLanguage({ configuredLanguage: 'pt-BR', voice: 'jf_alpha' })).toBe('pt-BR');
expect(resolveTtsLanguage({ configuredLanguage: 'auto', voice: 'jf_alpha' })).toBe('ja');
expect(toBaseLanguageCode('zh-CN')).toBe('zh');
});
test('normalizes Unicode tokens without dropping non-Latin scripts', () => {
expect(normalizeUnicodeToken('École!')).toBe('école');
expect(normalizeUnicodeToken('日本語。')).toBe('日本語');
expect(normalizeUnicodeToken('العربية؟')).toBe('العربية');
expect(normalizeUnicodeToken('हिन्दी।')).toBe('हिन्दी');
});
test('segments CJK sentences and no-space language words', () => {
expect(segmentSentences('これは最初の文です。これは二番目の文です。', 'ja')).toEqual([
'これは最初の文です。',
'これは二番目の文です。',
]);
const chineseWords = segmentWords('这是第一句话。', 'zh').map((token) => token.text);
expect(chineseWords.length).toBeGreaterThan(1);
expect(chineseWords.join('')).toBe('这是第一句话');
});
test('preserves source offsets for Thai word segmentation', () => {
const text = 'นี่คือประโยคแรก';
const words = segmentWords(text, 'th');
expect(words.length).toBeGreaterThan(1);
for (const word of words) {
expect(text.slice(word.start, word.end)).toBe(word.text);
}
});
});

View file

@ -46,6 +46,20 @@ describe('preprocessSentenceForAudio', () => {
});
describe('splitTextToTtsBlocks (PDF-oriented)', () => {
test('uses locale-aware sentence boundaries for Japanese and Chinese', () => {
expect(splitTextToTtsBlocks(
'これは最初の文です。これは二番目の文です。',
{ language: 'ja', maxBlockLength: 50 },
)).toEqual(['これは最初の文です。これは二番目の文です。']);
const chinese = splitTextToTtsBlocks(
Array(12).fill('这是一个用于测试分句的中文句子。').join(''),
{ language: 'zh', maxBlockLength: 50 },
);
expect(chinese.length).toBeGreaterThan(1);
expect(chinese.join('')).toContain('用于测试分句');
});
test('returns [] for empty input', () => {
expect(splitTextToTtsBlocks('')).toEqual([]);
expect(splitTextToTtsBlocks(' ')).toEqual([]);
@ -201,4 +215,11 @@ describe('normalizeTextForTts', () => {
expect(normalized).not.toMatch(/\s{2,}/);
expect(normalized.length).toBeGreaterThan(0);
});
test('does not insert spaces between normalized Japanese blocks', () => {
expect(normalizeTextForTts('最初の文です。次の文です。', {
language: 'ja',
maxBlockLength: 7,
})).toBe('最初の文です。次の文です。');
});
});

View file

@ -195,4 +195,19 @@ describe('tts segment helpers', () => {
expect(alignment.words[2].endSec).toBeGreaterThan(1.4);
expect(alignment.words[1].charStart).toBeGreaterThan(alignment.words[0].charStart);
});
test('builds proportional alignment for no-space languages', () => {
const sentence = 'これは日本語です';
const alignment = buildProportionalAlignment({
sentence,
sentenceIndex: 2,
durationMs: 1200,
language: 'ja',
});
expect(alignment.words.length).toBeGreaterThan(1);
for (const word of alignment.words) {
expect(sentence.slice(word.charStart, word.charEnd)).toBe(word.text);
}
});
});