diff --git a/README.md b/README.md
index 6ded1a6..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 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**.
@@ -22,6 +22,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader
- ⏱️ **Word-by-word highlighting** via ONNX Whisper alignment through the compute worker control plane (NATS JetStream-backed).
- ⚡ **Segment-based read-along** for EPUB, PDF, TXT, MD, and DOCX — sentence-aware TTS with cached audio segments, background preloading, and resumable playback.
- 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra).
+- 🌐 **Multilingual support** — choose a document language for language-aware narration and highlighting. Available languages depend on the configured TTS provider and voice.
- 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing.
- 🗂️ **Flexible backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync.
- 🐳 **Self-host friendly** — Docker (amd64/arm64), built-in auth/session support, and automatic startup migrations.
diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md
index 6a43df7..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 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**.
@@ -21,6 +21,9 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c
- 🎯 **Multi-Provider TTS Support**
- Self-hosted: [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI) (multi-voice combinations), [**KittenTTS-FastAPI**](https://github.com/richardr1126/KittenTTS-FastAPI), [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI), or any custom OpenAI-compatible endpoint
- Cloud: [**OpenAI**](https://platform.openai.com/docs/pricing#transcription-and-speech) (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), [**Replicate**](https://replicate.com/explore) (built-in catalog + any model ID), [**DeepInfra**](https://deepinfra.com/models/text-to-speech) (Kokoro-82M and others)
+- 🌐 **Multilingual Support**
+ - Choose a document language for language-aware narration and highlighting
+ - Available languages depend on the configured provider, model, and voice
- 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation
- 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync
- 🔐 **Auth and User Isolation** — auth is required in v4+, with optional anonymous auth sessions for guest flows
diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx
index b08d4d4..38472f8 100644
--- a/src/app/(app)/epub/[id]/page.tsx
+++ b/src/app/(app)/epub/[id]/page.tsx
@@ -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';
@@ -36,8 +37,10 @@ export default function EPUBPage() {
createFullAudioBook: createEPUBAudioBook,
regenerateChapter: regenerateEPUBChapter,
bookRef,
+ metadataLanguage,
} = epubState;
- const { stop } = useTTS();
+ const { stop, setDocumentLanguage } = useTTS();
+ const { language, updateLanguage } = useDocumentLanguage(routeDocumentId);
const { isAtLimit } = useAuthRateLimit();
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -106,6 +109,10 @@ export default function EPUBPage() {
useUnmountCleanupRef(clearCurrDoc);
+ useEffect(() => {
+ setDocumentLanguage(language === 'auto' ? metadataLanguage ?? 'auto' : language);
+ }, [language, metadataLanguage, setDocumentLanguage]);
+
// Compute available height = viewport - (header height + tts bar height)
useEffect(() => {
const compute = () => {
@@ -249,6 +256,11 @@ export default function EPUBPage() {
epub
isOpen={activeSidebar === 'settings'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
+ language={language}
+ detectedLanguage={metadataLanguage}
+ onLanguageChange={(nextLanguage) => {
+ void updateLanguage(nextLanguage);
+ }}
/>
Promise;
clearCurrDoc: () => void;
@@ -93,7 +95,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,
@@ -107,6 +118,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
const [currDocData, setCurrDocData] = useState();
const [currDocName, setCurrDocName] = useState();
const [currDocText, setCurrDocText] = useState();
+ const [metadataLanguage, setMetadataLanguage] = useState(null);
const [isPlaybackReady, setIsPlaybackReady] = useState(false);
// Mirror state into a ref so resolveEpubLocator (registered once with
// TTSContext via a stable callback) can always read the latest page text
@@ -145,6 +157,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
currentWordHighlightCfiRef: currentWordHighlightCfi,
renderedTextMapsRef,
wordHighlightMapCacheRef,
+ language: resolvedLanguage,
});
useEffect(() => () => {
@@ -162,6 +175,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
setCurrDocData(undefined);
setCurrDocName(undefined);
setCurrDocText(undefined);
+ setMetadataLanguage(null);
setIsPlaybackReady(false);
setCurrDocPages(undefined);
isEPUBSetOnce.current = false;
@@ -183,6 +197,9 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
const setCurrentDocument = useCallback(async (id: string): Promise => {
try {
setIsPlaybackReady(false);
+ setMetadataLanguage(null);
+ bookRef.current = null;
+ renditionRef.current = undefined;
const meta = await getDocumentMetadata(id);
if (!meta) {
clearCurrDoc();
@@ -370,8 +387,19 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
});
const setRendition = useCallback((rendition: Rendition) => {
- bookRef.current = rendition.book;
+ const book = rendition.book;
+ bookRef.current = book;
renditionRef.current = rendition;
+ void book.loaded.metadata
+ .then((metadata) => {
+ if (bookRef.current !== book) return;
+ setMetadataLanguage(normalizeOptionalLanguageTag(metadata.language));
+ })
+ .catch((error) => {
+ if (bookRef.current !== book) return;
+ setMetadataLanguage(null);
+ console.warn('Failed to read EPUB language metadata:', error);
+ });
}, []);
const handleLocationChanged = useEPUBLocationController({
@@ -396,6 +424,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
currDocPages,
currDocPage,
currDocText,
+ metadataLanguage,
isPlaybackReady,
clearCurrDoc,
extractPageText,
@@ -422,6 +451,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
currDocPages,
currDocPage,
currDocText,
+ metadataLanguage,
isPlaybackReady,
clearCurrDoc,
extractPageText,
diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx
index 09a0b45..ef0ba89 100644
--- a/src/app/(app)/html/[id]/page.tsx
+++ b/src/app/(app)/html/[id]/page.tsx
@@ -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(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);
+ }}
/>
setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
+ language={documentSettings.language ?? 'auto'}
+ onLanguageChange={(language) => {
+ void updateDocumentSettings({
+ ...documentSettings,
+ schemaVersion: 1,
+ language,
+ });
+ }}
pdf={{
parseStatus,
parsedOverlayEnabled,
diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts
index 8ec76c4..86cebf9 100644
--- a/src/app/(app)/pdf/[id]/usePdfDocument.ts
+++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts
@@ -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(null);
const [, setActiveParseOpId] = useState(null);
const [documentSettings, setDocumentSettings] = useState(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({
diff --git a/src/app/(app)/signin/page.tsx b/src/app/(app)/signin/page.tsx
index b5fb29b..853f246 100644
--- a/src/app/(app)/signin/page.tsx
+++ b/src/app/(app)/signin/page.tsx
@@ -9,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { GithubIcon } from '@/components/icons/Icons';
import { LoadingSpinner } from '@/components/Spinner';
-import { Button, Field, Input, Surface } from '@/components/ui';
+import { Button, Checkbox, Field, InlineButton, Input, Surface } from '@/components/ui';
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
const searchParams = useSearchParams();
@@ -169,11 +169,9 @@ function SignInContent() {
{/* Remember Me */}
- setRememberMe(e.target.checked)}
- className="rounded border-muted text-accent focus:ring-accent-line"
/>
Remember me
@@ -238,12 +236,9 @@ function SignInContent() {
)}
By signing in, you agree to our{' '}
- showPrivacyModal()}
- className="underline hover:text-foreground"
- >
+ showPrivacyModal()}>
Privacy Policy
-
+
diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx
index f2b1ad8..f5833cc 100644
--- a/src/app/(app)/signup/page.tsx
+++ b/src/app/(app)/signup/page.tsx
@@ -8,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { showPrivacyModal } from '@/components/PrivacyModal';
import { LoadingSpinner } from '@/components/Spinner';
-import { Button, Field, IconButton, Input, Surface } from '@/components/ui';
+import { Button, Field, IconButton, InlineButton, Input, Surface } from '@/components/ui';
import toast from 'react-hot-toast';
export default function SignUpPage() {
@@ -241,12 +241,9 @@ export default function SignUpPage() {
By creating an account, you agree to our{' '}
- showPrivacyModal()}
- className="underline hover:text-foreground"
- >
+ showPrivacyModal()}>
Privacy Policy
-
+
diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx
index a297501..89499fa 100644
--- a/src/app/(public)/page.tsx
+++ b/src/app/(public)/page.tsx
@@ -6,9 +6,9 @@ import { ButtonAnchor, ButtonLink } from '@/components/ui';
export const metadata: Metadata = {
title: 'Open Source Read-Along Workspace',
description:
- 'OpenReader converts EPUB, PDF, TXT, MD, and DOCX files into synchronized read-along audio with multi-provider text-to-speech support.',
+ 'OpenReader converts EPUB, PDF, TXT, MD, and DOCX files into multilingual, synchronized read-along audio with multi-provider text-to-speech support.',
keywords:
- 'OpenReader, document reader, PDF read aloud, EPUB read aloud, text to speech, OpenAI compatible TTS, self-hosted reader',
+ 'OpenReader, document reader, multilingual text to speech, PDF read aloud, EPUB read aloud, OpenAI compatible TTS, self-hosted reader',
alternates: {
canonical: '/',
},
@@ -19,7 +19,7 @@ export const metadata: Metadata = {
siteName: 'OpenReader',
title: 'OpenReader | Read documents with synchronized audio',
description:
- 'Upload documents and turn them into a synchronized listening experience with word-level highlighting and audiobook export.',
+ 'Upload documents and turn them into a multilingual, synchronized listening experience with word-level highlighting and audiobook export.',
images: [
{
url: '/web-app-manifest-512x512.png',
@@ -91,7 +91,7 @@ export default async function LandingPage() {
OpenReader turns EPUB, PDF, TXT, Markdown, and DOCX into a
synchronized read-along surface, reading your original file in a
- native viewer with genuine text-to-speech, word-level
+ native viewer with multilingual text-to-speech, language-aware
highlighting, and audiobook export. It’s open source, and
entirely yours to self-host.
@@ -126,7 +126,7 @@ export default async function LandingPage() {
wizard-of-oz.epub
- Kokoro · af_sky
+ English · Kokoro · af_sky
@@ -203,7 +203,8 @@ export default async function LandingPage() {
Choose a provider and model: hosted OpenAI, Replicate, or
DeepInfra, or your own self-hosted Kokoro, KittenTTS, or Orpheus
- server. Set the speed to your pace.
+ server. Set the document language, choose a compatible voice,
+ and adjust the speed to your pace.
@@ -251,11 +252,11 @@ export default async function LandingPage() {
- Voices
- Multi-provider TTS
+ Languages
+ Multilingual support
- Mix cloud APIs with OpenAI-compatible local servers. Bring your
- own keys and endpoints, with no lock-in to a single vendor.
+ Choose a document language for language-aware narration,
+ highlighting, and compatible voice selection.
diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts
index 70e78f7..e63d045 100644
--- a/src/app/api/audiobook/chapter/route.ts
+++ b/src/app/api/audiobook/chapter/route.ts
@@ -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 });
}
@@ -596,6 +597,7 @@ export async function POST(request: NextRequest) {
format: 'mp3',
model,
instructions,
+ language: mergedSettings?.language,
provider,
apiKey: openApiKey,
baseUrl: openApiBaseUrl,
diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts
index fdd6b2d..f089231 100644
--- a/src/app/api/tts/segments/ensure/route.ts
+++ b/src/app/api/tts/segments/ensure/route.ts
@@ -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;
@@ -637,6 +642,7 @@ export async function POST(request: NextRequest) {
format: 'mp3',
model: effectiveSettings.ttsModel,
instructions: effectiveSettings.ttsInstructions,
+ language: effectiveSettings.language,
provider: requestCreds.provider,
apiKey: requestCreds.apiKey || 'none',
baseUrl: requestCreds.baseUrl,
@@ -674,6 +680,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;
diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts
index c78e602..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,9 +72,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' ? normalizeLanguageTag(rec.language, 'en') : '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 {
diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx
index 0bd98b5..a50a928 100644
--- a/src/components/AudiobookExportModal.tsx
+++ b/src/components/AudiobookExportModal.tsx
@@ -1,19 +1,19 @@
'use client';
-import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
-import { Transition, Listbox, Menu, MenuButton } from '@headlessui/react';
+import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { ProgressPopup } from '@/components/ProgressPopup';
import { ProgressCard } from '@/components/ProgressCard';
-import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons';
+import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { useConfig } from '@/contexts/ConfigContext';
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 { getTtsLanguageCompatibilityWarnings, 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 { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, RangeInput, Select } from '@/components/ui';
import {
getAudiobookStatus,
deleteAudiobookChapter,
@@ -50,7 +50,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([]);
@@ -125,8 +125,17 @@ 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 languageWarnings = useMemo(() => getTtsLanguageCompatibilityWarnings({
+ model: effectiveSettings?.ttsModel,
+ voice: effectiveSettings?.voice,
+ documentLanguage: effectiveSettings?.language,
+ }), [effectiveSettings]);
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
if (soft) {
@@ -555,56 +564,35 @@ export function AudiobookExportModal({
Format
{chapters.length === 0 ? (
-
setFormat(newFormat)}
+ options={['m4b', 'mp3'] as const}
disabled={chapters.length > 0 || settingsLocked}
- >
-
-
- {format.toUpperCase()}
-
-
-
-
-
-
-
- {({ selected }) => (
-
- M4B
-
- )}
-
-
- {({ selected }) => (
-
- MP3
-
- )}
-
-
-
-
-
+ renderValue={(option) => (
+
{option.toUpperCase()}
+ )}
+ renderOption={(option, { selected }) => (
+
+ {option.toUpperCase()}
+
+ )}
+ buttonClassName="bg-surface"
+ chevronClassName="h-4 w-4 text-soft"
+ optionInset="none"
+ optionItemClassName="py-2"
+ showCheckmark={false}
+ />
) : (
{format.toUpperCase()}
)}
+ {languageWarnings.map((warning) => (
+
+ {warning}
+
+ ))}
{/* Speed controls */}
@@ -773,23 +761,15 @@ export function AudiobookExportModal({
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
-
-
+
-
-
+
+
{/* end of menu items */}
-
-
+
+
)}
diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx
index 374ec34..8a700d1 100644
--- a/src/components/ColorPicker.tsx
+++ b/src/components/ColorPicker.tsx
@@ -1,10 +1,9 @@
'use client';
import { useRef, useState, useEffect, useCallback } from 'react';
-import { Popover, PopoverButton } from '@headlessui/react';
import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
import { PaletteIcon } from '@/components/icons/Icons';
-import { IconButton, Input, PopoverSurface } from '@/components/ui';
+import { IconButton, Input, PopoverIconTrigger, PopoverRoot, PopoverSurface } from '@/components/ui';
/**
* Curated swatch palettes per color role, sourced from existing themes
@@ -78,8 +77,8 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
const swatches = ROLE_SWATCHES[field];
return (
-
-
+
+
-
+
-
+
);
}
diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx
index 600c7c7..a82e172 100644
--- a/src/components/PrivacyModal.tsx
+++ b/src/components/PrivacyModal.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react';
import { updateAppConfig } from '@/lib/client/dexie';
-import { Button, ModalFrame, ModalTitle } from '@/components/ui';
+import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
interface PrivacyModalProps {
isOpen: boolean;
@@ -78,13 +78,11 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
- setAgreed(e.target.checked)}
- className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface"
/>
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
index a5ad457..b9465ea 100644
--- a/src/components/SettingsModal.tsx
+++ b/src/components/SettingsModal.tsx
@@ -1,14 +1,10 @@
'use client';
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
-import {
- Transition,
- Listbox,
-} from '@headlessui/react';
import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
-import { ChevronUpDownIcon, CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons';
+import { CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ProgressPopup } from '@/components/ProgressPopup';
@@ -50,12 +46,10 @@ import {
ChoiceTile,
IconButton,
Input,
+ Textarea,
ModalFrame,
ModalTitle,
- inputClass,
- SharedListboxButton,
- SharedListboxOption,
- SharedListboxOptions,
+ Select,
} from '@/components/ui';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -654,8 +648,16 @@ export function SettingsModal({
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
) : (
- provider.id}
+ renderValue={(provider) => provider.name}
+ renderOption={(provider, { selected }) => (
+
+ {provider.name}
+
+ )}
onChange={(provider) => {
const defaults = resolveProviderDefaults({
providerRef: provider.id,
@@ -675,46 +677,7 @@ export function SettingsModal({
}
setCustomModelInput('');
}}
- >
-
-
- {selectedProviderOption?.name || 'Select Provider'}
-
-
-
-
-
-
-
- {ttsProviders.map((provider) => (
-
- {({ selected }) => (
- <>
-
- {provider.name}
-
- {selected && (
-
-
-
- )}
- >
- )}
-
- ))}
-
-
-
+ />
)}
{restrictUserApiKeys && (
@@ -734,7 +697,6 @@ export function SettingsModal({
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
- className={inputClass}
/>
)}
@@ -750,7 +712,6 @@ export function SettingsModal({
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder="Using environment variable"
- className={inputClass}
/>
)}
@@ -768,8 +729,30 @@ export function SettingsModal({
)}
- m.id === selectedModelId) || ttsModels[0]}
+ model.id}
+ renderValue={(model) => (
+
+ {model.name}
+ {selectedModelVersion ? (
+
+ {selectedModelVersion}
+
+ ) : null}
+
+ )}
+ renderOption={(model, { selected }) => (
+
+ {model.name}
+ {model.id.includes(':') ? (
+
+ {model.id.slice(model.id.indexOf(':'))}
+
+ ) : null}
+
+ )}
onChange={(model) => {
if (model.id === 'custom') {
setModelValue(customModelInput);
@@ -778,62 +761,7 @@ export function SettingsModal({
setCustomModelInput('');
}
}}
- >
-
- {selectedModel ? (
-
-
- {selectedModel.name}
-
- {selectedModelVersion && (
-
- {selectedModelVersion}
-
- )}
-
- ) : (
- Select Model
- )}
-
-
-
-
-
-
- {ttsModels.map((model) => (
-
- {({ selected }) => (
- <>
-
- {model.name}
- {model.id.includes(':') && (
-
- {model.id.slice(model.id.indexOf(':'))}
-
- )}
-
- {selected && (
-
-
-
- )}
- >
- )}
-
- ))}
-
-
-
+ />
{supportsCustom && selectedModelId === 'custom' && (
)}
@@ -853,11 +780,11 @@ export function SettingsModal({
{providerModelPolicy.supportsInstructions && (
TTS Instructions
-
)}
diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx
index 9ba1085..118c594 100644
--- a/src/components/admin/AdminFeaturesPanel.tsx
+++ b/src/components/admin/AdminFeaturesPanel.tsx
@@ -1,19 +1,15 @@
'use client';
-import { Fragment, useEffect, useMemo, useState } from 'react';
-import { Listbox, Transition } from '@headlessui/react';
+import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
-import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import {
Badge,
Section,
ToggleRow,
- inputClass,
- SharedListboxButton,
- SharedListboxOption,
- SharedListboxOptions,
+ Select,
Button,
+ Input,
} from '@/components/ui';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
@@ -204,42 +200,19 @@ export function AdminFeaturesPanel() {
{renderSource('defaultTtsProvider')}
{providerOptions.length > 0 ? (
-
-
- {selectedProviderOption?.name ?? 'Select provider'}
-
-
+ option.id}
+ renderValue={(option) => option.name}
+ renderOption={(option, { selected }) => (
+
+ {option.name}
-
-
-
- {providerOptions.map((opt) => (
-
- {({ selected }) => (
- <>
-
- {opt.name}
-
- {selected && (
-
-
-
- )}
- >
- )}
-
- ))}
-
-
-
+ )}
+ chevronClassName="h-4 w-4 text-muted"
+ />
) : (
No shared providers yet. Add one first.
@@ -301,11 +274,10 @@ export function AdminFeaturesPanel() {
Anonymous per-user daily limit
{renderSource('ttsDailyLimitAnonymous')}
- updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
/>
@@ -315,11 +287,10 @@ export function AdminFeaturesPanel() {
Authenticated per-user daily limit
{renderSource('ttsDailyLimitAuthenticated')}
- updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
/>
@@ -329,11 +300,10 @@ export function AdminFeaturesPanel() {
Anonymous IP daily backstop
{renderSource('ttsIpDailyLimitAnonymous')}
- updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
/>
@@ -343,11 +313,10 @@ export function AdminFeaturesPanel() {
Authenticated IP daily backstop
{renderSource('ttsIpDailyLimitAuthenticated')}
- updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
/>
@@ -370,11 +339,10 @@ export function AdminFeaturesPanel() {
Burst limit (parses)
{renderSource('computeParseBurstMax')}
- updatePositiveIntDraft('computeParseBurstMax', event.target.value)}
/>
@@ -384,11 +352,10 @@ export function AdminFeaturesPanel() {
Burst window (seconds)
{renderSource('computeParseBurstWindowSec')}
- updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)}
/>
@@ -398,11 +365,10 @@ export function AdminFeaturesPanel() {
Sustained limit (parses)
{renderSource('computeParseSustainedMax')}
- updatePositiveIntDraft('computeParseSustainedMax', event.target.value)}
/>
@@ -412,11 +378,10 @@ export function AdminFeaturesPanel() {
Sustained window (seconds)
{renderSource('computeParseSustainedWindowSec')}
- updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
/>
@@ -432,13 +397,13 @@ export function AdminFeaturesPanel() {
{renderSource('maxUploadMb')}
- updatePositiveIntDraft('maxUploadMb', event.target.value)}
/>
@@ -463,9 +428,8 @@ export function AdminFeaturesPanel() {
{renderSource('changelogFeedUrl')}
- updateDraft('changelogFeedUrl', event.target.value)}
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
@@ -508,11 +472,10 @@ export function AdminFeaturesPanel() {
Retry attempts
{renderSource('ttsUpstreamMaxRetries')}
- updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)}
/>
@@ -522,11 +485,10 @@ export function AdminFeaturesPanel() {
Upstream timeout (ms)
{renderSource('ttsUpstreamTimeoutMs')}
- updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)}
/>
@@ -536,11 +498,10 @@ export function AdminFeaturesPanel() {
Audio cache size (bytes)
{renderSource('ttsCacheMaxSizeBytes')}
- updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)}
/>
@@ -550,11 +511,10 @@ export function AdminFeaturesPanel() {
Audio cache TTL (ms)
{renderSource('ttsCacheTtlMs')}
- updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)}
/>
diff --git a/src/components/admin/AdminProvidersPanel.tsx b/src/components/admin/AdminProvidersPanel.tsx
index 55dfc0f..4a2f202 100644
--- a/src/components/admin/AdminProvidersPanel.tsx
+++ b/src/components/admin/AdminProvidersPanel.tsx
@@ -1,27 +1,26 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { Listbox, Menu, MenuButton, Transition } from '@headlessui/react';
-import { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
-import { ChevronUpDownIcon, CheckIcon, DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
-import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
+import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
+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,
Field,
Section,
ToggleRow,
- inputClass,
- SharedListboxButton,
- SharedListboxOption,
- SharedListboxOptions,
+ Select,
Button,
IconButton,
Input,
+ Textarea,
MenuItemsSurface,
MenuActionItem,
+ MenuRoot,
+ MenuTrigger,
+ MenuTransition,
} from '@/components/ui';
type ProviderType = TtsProviderId;
@@ -328,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'
@@ -396,7 +395,6 @@ export function AdminProvidersPanel() {
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
placeholder="kokoro-prod"
- className={inputClass}
disabled={isEditingExisting}
/>
@@ -406,12 +404,20 @@ export function AdminProvidersPanel() {
value={form.displayName}
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
placeholder="Kokoro (production)"
- className={inputClass}
/>
- option.value}
+ renderValue={(option) => option.label}
+ renderOption={(option, { selected }) => (
+
+ {option.label}
+
+ )}
+ chevronClassName="h-4 w-4 text-muted"
onChange={(opt) => {
const nextModel = providerDefaultModel(opt.value);
setForm({
@@ -419,53 +425,33 @@ 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('');
}}
- >
-
- {selectedProviderType.label}
-
-
-
-
-
-
- {PROVIDER_TYPE_OPTIONS.map((opt) => (
-
- {({ selected }) => (
- <>
-
- {opt.label}
-
- {selected && (
-
-
-
- )}
- >
- )}
-
- ))}
-
-
-
+ />
- {
- if (modelId === 'custom') {
+ model.id}
+ renderValue={(model) => model.name}
+ renderOption={(model, { selected }) => (
+
+ {model.name}
+ {model.id.includes(':') ? (
+
+ {model.id.slice(model.id.indexOf(':'))}
+
+ ) : null}
+
+ )}
+ placeholder="Select model"
+ chevronClassName="h-4 w-4 text-muted"
+ onChange={(model) => {
+ if (model.id === 'custom') {
const nextModel = customModelInput.trim();
setForm({
...form,
@@ -476,54 +462,12 @@ export function AdminProvidersPanel() {
}
setForm({
...form,
- defaultModel: modelId,
- defaultInstructions: modelSupportsInstructions(modelId) ? form.defaultInstructions : '',
+ defaultModel: model.id,
+ defaultInstructions: modelSupportsInstructions(model.id) ? form.defaultInstructions : '',
});
setCustomModelInput('');
}}
- >
-
-
- {selectedModelDefinition?.name ?? 'Select model'}
-
-
-
-
-
-
-
- {modelDefinitions.map((model) => (
-
- {({ selected }) => (
- <>
-
- {model.name}
- {model.id.includes(':') && (
-
- {model.id.slice(model.id.indexOf(':'))}
-
- )}
-
- {selected && (
-
-
-
- )}
- >
- )}
-
- ))}
-
-
-
+ />
{supportsCustomModel && selectedModelId === 'custom' && (
)}
@@ -549,11 +492,11 @@ export function AdminProvidersPanel() {
className="sm:col-span-2"
hint="Optional. Applied by default when this shared provider is selected."
>
-
)}
@@ -564,7 +507,6 @@ export function AdminProvidersPanel() {
value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
placeholder={baseUrlPlaceholder}
- className={inputClass}
/>
)}
@@ -578,7 +520,6 @@ export function AdminProvidersPanel() {
value={form.apiKey}
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
placeholder={isEditingExisting ? `keep existing (${editingProvider?.apiKeyMask ?? ''})` : 'Optional'}
- className={inputClass}
/>
@@ -639,8 +580,8 @@ export function AdminProvidersPanel() {
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
-
-
+
-
-
+
+
-
-
+
+
))
diff --git a/src/components/doclist/window/FinderSidebar.tsx b/src/components/doclist/window/FinderSidebar.tsx
index 20ee1c3..0b0d0ed 100644
--- a/src/components/doclist/window/FinderSidebar.tsx
+++ b/src/components/doclist/window/FinderSidebar.tsx
@@ -1,13 +1,12 @@
'use client';
-import { Menu, MenuButton, Transition } from '@headlessui/react';
-import { Fragment, useRef, type CSSProperties, type ReactNode } from 'react';
+import { useRef, type CSSProperties, type ReactNode } from 'react';
import { useDrop } from 'react-dnd';
import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
-import { IconButton, MenuActionItem, MenuItemsSurface, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
+import { IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem } from '@/components/ui';
interface FinderSidebarProps {
filter: SidebarFilter;
@@ -201,8 +200,8 @@ export function FinderSidebar({
-
+
-
-
+
+
-
-
+
+
)}
>
Folders
diff --git a/src/components/documents/DocumentHeaderMenu.tsx b/src/components/documents/DocumentHeaderMenu.tsx
index b44a479..e8a1f61 100644
--- a/src/components/documents/DocumentHeaderMenu.tsx
+++ b/src/components/documents/DocumentHeaderMenu.tsx
@@ -1,11 +1,9 @@
'use client';
-import { Menu, MenuButton, Transition } from '@headlessui/react';
-import { Fragment } from 'react';
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
import { ZoomControl } from '@/components/documents/ZoomControl';
import { UserMenu } from '@/components/auth/UserMenu';
-import { IconButton, MenuActionItem, MenuItemsSurface, ToolbarButton } from '@/components/ui';
+import { IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, ToolbarButton } from '@/components/ui';
interface DocumentHeaderMenuProps {
zoomLevel: number;
@@ -82,24 +80,16 @@ export function DocumentHeaderMenu({
// --- Mobile View ---
const MobileView = (
-
-
+
-
-
+
+
{/* Zoom Controls Section */}
@@ -140,8 +130,8 @@ export function DocumentHeaderMenu({
-
-
+
+
);
diff --git a/src/components/documents/DocumentSelectionModal.tsx b/src/components/documents/DocumentSelectionModal.tsx
index a89b152..a930cd9 100644
--- a/src/components/documents/DocumentSelectionModal.tsx
+++ b/src/components/documents/DocumentSelectionModal.tsx
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react';
import { BaseDocument } from '@/types/documents';
-import { Button, ModalFrame, ModalTitle } from '@/components/ui';
+import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
interface DocumentSelectionModalProps {
isOpen: boolean;
@@ -119,9 +119,7 @@ export function DocumentSelectionModal({
{files.length > 0 && (
- {
if (input) input.indeterminate = isIndeterminate;
@@ -154,11 +152,9 @@ export function DocumentSelectionModal({
}`}
>
e.stopPropagation()}>
- handleCheckboxChange(file.id, e.target.checked)}
- className="rounded border-muted text-accent focus:ring-accent-line"
/>
= [
{ kind: 'header', label: 'Header' },
@@ -50,6 +60,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 +116,14 @@ function RangeSetting({
);
}
-export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
+export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, detectedLanguage, onLanguageChange, pdf }: {
isOpen: boolean,
setIsOpen: (isOpen: boolean) => void,
epub?: boolean,
html?: boolean,
+ language?: string,
+ detectedLanguage?: string | null,
+ onLanguageChange?: (language: string) => void,
pdf?: {
parseStatus: PdfParseStatus | null;
parsedOverlayEnabled: boolean;
@@ -121,7 +148,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
epubWordHighlightEnabled,
htmlHighlightEnabled,
htmlWordHighlightEnabled,
+ ttsModel,
} = useConfig();
+ const { voice, resolvedLanguage } = useTTS();
+ const languageWarnings = getTtsLanguageCompatibilityWarnings({
+ model: ttsModel,
+ voice,
+ documentLanguage: resolvedLanguage,
+ });
+ const selectedLanguage = DOCUMENT_LANGUAGE_OPTIONS.find((option) => option.value === language)
+ ?? DOCUMENT_LANGUAGE_OPTIONS[0];
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
const isPdfMode = !epub && !html && !!pdf;
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
@@ -151,6 +187,34 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
panelClassName="w-full sm:w-[30rem]"
>
+ {language && onLanguageChange ? (
+
+
+
+ Document language
+
+ onLanguageChange(option.value)}
+ options={DOCUMENT_LANGUAGE_OPTIONS}
+ />
+
+ {language === 'auto' && detectedLanguage ? (
+
+ Detected from document metadata: {getLanguageDisplayName(detectedLanguage)}
+
+ ) : null}
+ {languageWarnings.map((warning) => (
+
+ {warning}
+
+ ))}
+
+ ) : null}
{isPdfMode && pdf && (
{/* Page number popup */}
-
+
{currentPage} / {numPages || 1}
@@ -92,7 +91,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
of {numPages || 1}
-
+
{/* Page forward */}
+
{compactTriggerLabel}
@@ -148,6 +147,6 @@ export const SpeedControl = ({
-
+
);
};
diff --git a/src/components/player/VoicesControl.tsx b/src/components/player/VoicesControl.tsx
index 2e67b86..b2b8661 100644
--- a/src/components/player/VoicesControl.tsx
+++ b/src/components/player/VoicesControl.tsx
@@ -4,22 +4,40 @@ import { useConfig } from '@/contexts/ConfigContext';
import { useTTS } from '@/contexts/TTSContext';
import { useCallback } from 'react';
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
+import { InfoIcon } from '@/components/icons/Icons';
+import { getTtsLanguageCompatibilityWarnings } from '@/lib/shared/language';
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
availableVoices: string[];
setVoiceAndRestart: (voice: string) => void;
}) => {
const { ttsModel, providerType } = useConfig();
- const { voice } = useTTS();
+ const { voice, resolvedLanguage } = useTTS();
const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]);
+ const languageWarnings = getTtsLanguageCompatibilityWarnings({
+ model: ttsModel,
+ voice,
+ documentLanguage: resolvedLanguage,
+ });
return (
-
+
+
+ {languageWarnings.length > 0 ? (
+
+
+
+ ) : null}
+
);
}
diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx
index fd2cad9..f2e4a3e 100644
--- a/src/components/player/VoicesControlBase.tsx
+++ b/src/components/player/VoicesControlBase.tsx
@@ -4,8 +4,9 @@ import {
Listbox,
} from '@headlessui/react';
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
-import { useEffect, useMemo, useState } from 'react';
+import { useMemo } from 'react';
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
+import { keepKokoroVoicesInOneLanguage } from '@/lib/shared/language';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui';
@@ -55,10 +56,15 @@ export function VoicesControlBase({
const isKokoro = providerModelPolicy.isKokoroModel;
const maxVoices = providerModelPolicy.maxVoices;
- const [selectedVoices, setSelectedVoices] = useState([]);
-
- useEffect(() => {
- if (!(isKokoro && maxVoices > 1)) return;
+ // Selected voices are derived from the `voice` prop for display only. The
+ // control is fully controlled: it never writes back to config as a side
+ // effect, only in direct response to a user selection (see onChange below).
+ // A write-back effect here would re-fire during the availableVoices refetch
+ // churn that a voice change triggers and, under a slow/contended backend,
+ // push a transiently-collapsed selection back as the voice — resetting it to
+ // the first option.
+ const selectedVoices = useMemo(() => {
+ if (!(isKokoro && maxVoices > 1)) return [];
let initial: string[] = [];
if (voice && voice.includes('+')) {
initial = parseKokoroVoiceNames(voice);
@@ -70,7 +76,7 @@ export function VoicesControlBase({
if (initial.length > maxVoices) {
initial = initial.slice(0, maxVoices);
}
- setSelectedVoices(initial);
+ return keepKokoroVoicesInOneLanguage(initial);
}, [isKokoro, maxVoices, voice, availableVoices]);
const currentVoice = useMemo(() => {
@@ -101,19 +107,22 @@ export function VoicesControlBase({
onChange={(vals: string[]) => {
if (!vals || vals.length === 0) return;
- let next = vals;
- if (vals.length > maxVoices) {
- const newlyAdded = vals.find((v) => !selectedVoices.includes(v));
+ const newlyAdded = vals.find((v) => !selectedVoices.includes(v));
+ let next = keepKokoroVoicesInOneLanguage(vals, newlyAdded);
+ if (next.length > maxVoices) {
if (newlyAdded) {
- const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? '';
- const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean);
- next = pair.slice(0, maxVoices);
+ const compatiblePrevious = selectedVoices.filter(
+ (voiceId) => voiceId !== newlyAdded && next.includes(voiceId),
+ );
+ next = [
+ ...compatiblePrevious.slice(-(maxVoices - 1)),
+ newlyAdded,
+ ];
} else {
- next = vals.slice(-maxVoices);
+ next = next.slice(-maxVoices);
}
}
- setSelectedVoices(next);
const combined = buildKokoroVoiceString(next);
if (combined) {
onChangeVoice(combined);
diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx
index d9ff378..33d2434 100644
--- a/src/components/reader/SegmentsSidebar.tsx
+++ b/src/components/reader/SegmentsSidebar.tsx
@@ -1,14 +1,14 @@
'use client';
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Popover, PopoverButton, Transition } from '@headlessui/react';
+import { Transition } from '@headlessui/react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { Book } from 'epubjs';
import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
-import { Button, IconButton, PopoverSurface } from '@/components/ui';
+import { Button, IconButton, PopoverIconTrigger, PopoverRoot, PopoverSurface } from '@/components/ui';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
@@ -94,6 +94,7 @@ function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings |
&& a.voice === b.voice
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
&& (a.ttsInstructions || '') === (b.ttsInstructions || '')
+ && (a.language || 'en') === (b.language || 'en')
);
}
@@ -186,6 +187,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
playFromSegment,
activeReaderType,
clearSegmentCaches,
+ resolvedLanguage,
+ setDocumentLanguage,
} = useTTS();
const {
providerRef,
@@ -348,7 +351,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
voice,
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
ttsInstructions: ttsInstructions || '',
- }), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions]);
+ language: resolvedLanguage,
+ }), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions, resolvedLanguage]);
const handleClearCache = useCallback(async () => {
if (!documentId || isClearingSegments) return;
@@ -418,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;
@@ -885,15 +890,14 @@ function SegmentsListSkeletonRows() {
function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
return (
-
-
+
-
+
-
+
);
}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index d31b6b0..e88a3e5 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -7,7 +7,7 @@ import { focusRing, motionColors } from './tokens';
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
-export const buttonStyles = variants({
+const buttonStyles = variants({
base: cn(
'inline-flex items-center justify-center font-medium disabled:cursor-not-allowed disabled:opacity-50',
focusRing,
@@ -35,17 +35,6 @@ export const buttonStyles = variants({
},
});
-export const btnBase = cn(
- 'inline-flex items-center justify-center rounded-md text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50',
- focusRing,
- motionColors,
-);
-export const btnPrimary = buttonStyles({ variant: 'primary', size: 'md' });
-export const btnSecondary = buttonStyles({ variant: 'secondary', size: 'md' });
-export const btnOutline = buttonStyles({ variant: 'outline', size: 'md' });
-export const btnDanger = buttonStyles({ variant: 'danger', size: 'md' });
-export const btnGhost = buttonStyles({ variant: 'ghost', size: 'md' });
-
function buttonClass({
variant = 'secondary',
size = 'md',
@@ -112,3 +101,22 @@ export function ButtonAnchor({
);
}
+
+export function InlineButton({
+ className,
+ children,
+ type = 'button',
+ ...props
+}: ButtonHTMLAttributes & {
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..1433ee5
--- /dev/null
+++ b/src/components/ui/checkbox.tsx
@@ -0,0 +1,16 @@
+import { forwardRef, type InputHTMLAttributes } from 'react';
+import { cn } from './cn';
+import { focusRing, motionColors } from './tokens';
+
+export const checkboxClass = cn(
+ 'h-4 w-4 rounded border-line bg-surface text-accent disabled:cursor-not-allowed disabled:opacity-50',
+ focusRing,
+ motionColors,
+);
+
+export const Checkbox = forwardRef<
+ HTMLInputElement,
+ Omit, 'type'>
+>(function Checkbox({ className, ...props }, ref) {
+ return ;
+});
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 08926c5..0e9dda5 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1,5 +1,6 @@
export * from './badge';
export * from './button';
+export * from './checkbox';
export * from './choice-tile';
export * from './cn';
export * from './dialog';
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 9d59894..20fc557 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -5,7 +5,7 @@ import { motionColors } from './tokens';
export type InputControlSize = 'sm' | 'md' | 'lg';
-export const inputStyles = variants({
+const inputStyles = variants({
base: cn('w-full border border-line bg-surface-sunken text-foreground placeholder:text-soft focus:border-accent-line focus:outline-none focus:ring-2 focus:ring-accent-line', motionColors),
variants: {
size: {
@@ -19,8 +19,6 @@ export const inputStyles = variants({
},
});
-export const inputClass = inputStyles();
-
export const Input = forwardRef & { controlSize?: InputControlSize }>(function Input({
className,
controlSize = 'md',
diff --git a/src/components/ui/menu.tsx b/src/components/ui/menu.tsx
index 583fe4b..8917a36 100644
--- a/src/components/ui/menu.tsx
+++ b/src/components/ui/menu.tsx
@@ -1,19 +1,33 @@
-import { MenuItem, MenuItems } from '@headlessui/react';
+import {
+ Menu as HeadlessMenu,
+ MenuButton as HeadlessMenuButton,
+ MenuItem,
+ MenuItems,
+ Transition,
+} from '@headlessui/react';
+import { Fragment } from 'react';
import type { ComponentProps } from 'react';
-import type { HTMLAttributes, ReactNode } from 'react';
+import type { ReactNode } from 'react';
import { cn } from './cn';
const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
-export function Menu({
- children,
- className,
- ...props
-}: HTMLAttributes & { children: ReactNode }) {
+export const MenuRoot = HeadlessMenu;
+export const MenuTrigger = HeadlessMenuButton;
+
+export function MenuTransition({ children }: { children: ReactNode }) {
return (
-
+
{children}
-
+
);
}
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
index f18cfba..eae5ce2 100644
--- a/src/components/ui/popover.tsx
+++ b/src/components/ui/popover.tsx
@@ -1,12 +1,15 @@
-import { PopoverButton, PopoverPanel } from '@headlessui/react';
+import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
import type { ComponentProps } from 'react';
import { cn } from './cn';
+import { IconButton } from './icon-button';
const popoverPanelClass = cn(
'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none',
);
-export const popoverTriggerClass = cn(
+export const PopoverRoot = Popover;
+
+const popoverTriggerClass = cn(
'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard',
);
@@ -22,6 +25,17 @@ export function PopoverTrigger({
);
}
+export function PopoverIconTrigger({
+ children,
+ ...props
+}: ComponentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
export function PopoverSurface({
className,
children,
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
index 0376e1c..eaea2a1 100644
--- a/src/components/ui/select.tsx
+++ b/src/components/ui/select.tsx
@@ -1,9 +1,9 @@
'use client';
-import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
-import type { ComponentProps } from 'react';
+import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
+import { Fragment, type ComponentProps, type Key, type ReactNode } from 'react';
import { cn } from './cn';
-import { CheckIcon, ChevronRightIcon } from '@/components/icons/Icons';
+import { CheckIcon, ChevronUpDownIcon } from '@/components/icons/Icons';
const listboxButtonClass =
'relative w-full cursor-pointer rounded-md bg-surface-sunken border border-line py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent-line hover:bg-accent-wash transition-colors duration-fast ease-standard';
@@ -104,39 +104,92 @@ export function SharedListboxOption({
);
}
-export function Select({
+export type SelectOption = {
+ value: string;
+ label: string;
+};
+
+export function Select({
value,
onChange,
options,
+ getOptionKey,
+ renderValue,
+ renderOption,
+ placeholder = 'Select',
+ disabled,
+ buttonClassName,
+ optionsClassName,
+ optionInset = 'check',
+ optionItemClassName,
+ showCheckmark = true,
+ chevronClassName = 'h-5 w-5 text-soft',
}: {
- value: string;
- onChange: (value: string) => void;
- options: Array<{ value: string; label: string }>;
+ value: T | undefined;
+ onChange: (value: T) => void;
+ options: readonly T[];
+ getOptionKey?: (option: T) => Key;
+ renderValue?: (option: T) => ReactNode;
+ renderOption?: (option: T, state: { selected: boolean }) => ReactNode;
+ placeholder?: ReactNode;
+ disabled?: boolean;
+ buttonClassName?: string;
+ optionsClassName?: string;
+ optionInset?: 'check' | 'none';
+ optionItemClassName?: string;
+ showCheckmark?: boolean;
+ chevronClassName?: string;
}) {
- const activeOption = options.find((option) => option.value === value) ?? options[0];
+ const defaultKey = (option: T): Key => {
+ if (typeof option === 'string' || typeof option === 'number') return option;
+ const record = option as Record;
+ return String(record.value ?? record.id ?? record.label);
+ };
+ const defaultRender = (option: T): ReactNode => {
+ if (typeof option === 'string' || typeof option === 'number') return String(option);
+ const record = option as Record;
+ return String(record.label ?? record.name ?? record.value ?? record.id ?? '');
+ };
+ const optionKey = getOptionKey ?? defaultKey;
+ const valueRenderer = renderValue ?? defaultRender;
+ const optionRenderer = renderOption ?? ((option: T) => valueRenderer(option));
return (
-
-
- {activeOption?.label ?? 'Select'}
-
-
+
+
+ {value === undefined ? placeholder : valueRenderer(value)}
+
+
-
- {options.map((option) => (
-
- {({ selected }) => (
- <>
-
- {selected ? : null}
-
- {option.label}
- >
- )}
-
- ))}
-
+
+
+ {options.map((option) => (
+
+ {({ selected }) => (
+ <>
+ {optionRenderer(option, { selected })}
+ {showCheckmark && selected ? (
+
+
+
+ ) : null}
+ >
+ )}
+
+ ))}
+
+
);
}
diff --git a/src/components/views/HTMLViewer.tsx b/src/components/views/HTMLViewer.tsx
index d67c551..3dfe54f 100644
--- a/src/components/views/HTMLViewer.tsx
+++ b/src/components/views/HTMLViewer.tsx
@@ -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,
diff --git a/src/components/views/PDFViewer.tsx b/src/components/views/PDFViewer.tsx
index 721737c..2f9008d 100644
--- a/src/components/views/PDFViewer.tsx
+++ b/src/components/views/PDFViewer.tsx
@@ -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,
diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx
index b346baf..fb15eac 100644
--- a/src/contexts/TTSContext.tsx
+++ b/src/contexts/TTSContext.tsx
@@ -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 = [];
const seenCandidates = new Set();
@@ -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,
diff --git a/src/hooks/epub/useEPUBHighlighting.ts b/src/hooks/epub/useEPUBHighlighting.ts
index 8255074..7362d3e 100644
--- a/src/hooks/epub/useEPUBHighlighting.ts
+++ b/src/hooks/epub/useEPUBHighlighting.ts
@@ -4,11 +4,15 @@ import { useCallback, useEffect, type MutableRefObject, type RefObject } from 'r
import type { Rendition } from 'epubjs';
import {
- buildMonotonicWordToTokenMap,
buildWordHighlightCacheKey,
+ resolveAlignmentWordSourceRange,
tokenizeCanonicalSegment,
type EpubCanonicalWordToken,
} from '@/lib/client/epub/epub-word-highlight';
+import {
+ buildAlignmentTokenRanges,
+ type HighlightTokenRange,
+} from '@/lib/client/highlight-token-alignment';
import {
createRangeFromMappedOffsets,
resolveVisibleSegmentRange,
@@ -19,7 +23,7 @@ import type { TTSSentenceAlignment } from '@/types/tts';
export type EpubWordHighlightMapCache = {
key: string;
- wordToToken: number[];
+ wordToTokenRange: Array;
tokens: EpubCanonicalWordToken[];
};
@@ -30,6 +34,7 @@ type UseEpubHighlightingParams = {
currentWordHighlightCfiRef: MutableRefObject;
renderedTextMapsRef: MutableRefObject;
wordHighlightMapCacheRef: MutableRefObject;
+ language?: string;
};
type UseEpubHighlightingResult = {
@@ -52,6 +57,7 @@ export function useEPUBHighlighting({
currentWordHighlightCfiRef,
renderedTextMapsRef,
wordHighlightMapCacheRef,
+ language,
}: UseEpubHighlightingParams): UseEpubHighlightingResult {
const clearWordHighlights = useCallback(() => {
if (!renditionRef.current) return;
@@ -116,25 +122,68 @@ export function useEPUBHighlighting({
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return;
- const cacheKey = buildWordHighlightCacheKey(segment, alignment);
+ const alignmentRange = resolveAlignmentWordSourceRange(segment, words[wordIndex]);
+ if (
+ alignmentRange
+ && alignmentRange.sourceStart >= resolved.startOffset
+ && alignmentRange.sourceEnd <= resolved.endOffset
+ ) {
+ const wordRange = createRangeFromMappedOffsets(
+ resolved.map,
+ alignmentRange.sourceStart,
+ alignmentRange.sourceEnd,
+ );
+ if (wordRange) {
+ try {
+ const wordCfi = resolved.map.content.cfiFromRange(wordRange);
+ currentWordHighlightCfiRef.current = wordCfi;
+ renditionRef.current.annotations.add(
+ 'highlight',
+ wordCfi,
+ {},
+ () => { },
+ '',
+ {
+ fill: 'var(--accent)',
+ 'fill-opacity': '0.4',
+ 'mix-blend-mode': 'multiply',
+ }
+ );
+ return;
+ } catch (error) {
+ console.error('Error highlighting EPUB word from alignment offsets:', error);
+ }
+ }
+ }
+
+ 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,
- wordToToken: buildMonotonicWordToTokenMap(words, tokens),
+ wordToTokenRange: buildAlignmentTokenRanges(
+ words,
+ tokens.map((token) => token.norm),
+ { minimumSimilarity: 0.8 },
+ ),
};
}
const cached = wordHighlightMapCacheRef.current;
- const tokenIndex = cached.wordToToken[wordIndex] ?? -1;
- if (tokenIndex < 0) return;
+ const tokenRange = cached.wordToTokenRange[wordIndex];
+ if (!tokenRange) return;
- const token = cached.tokens[tokenIndex];
- if (!token) return;
- if (token.sourceStart < resolved.startOffset || token.sourceEnd > resolved.endOffset) return;
+ const firstToken = cached.tokens[tokenRange.start];
+ const lastToken = cached.tokens[tokenRange.end];
+ if (!firstToken || !lastToken) return;
+ if (firstToken.sourceStart < resolved.startOffset || lastToken.sourceEnd > resolved.endOffset) return;
- const wordRange = createRangeFromMappedOffsets(resolved.map, token.sourceStart, token.sourceEnd);
+ const wordRange = createRangeFromMappedOffsets(
+ resolved.map,
+ firstToken.sourceStart,
+ lastToken.sourceEnd,
+ );
if (!wordRange) return;
try {
@@ -162,6 +211,7 @@ export function useEPUBHighlighting({
renderedTextMapsRef,
renditionRef,
wordHighlightMapCacheRef,
+ language,
]);
const setRenderedTextMaps = useCallback((maps: EpubRenderedTextMap[]) => {
diff --git a/src/hooks/useDocumentLanguage.ts b/src/hooks/useDocumentLanguage.ts
new file mode 100644
index 0000000..c8ca980
--- /dev/null
+++ b/src/hooks/useDocumentLanguage.ts
@@ -0,0 +1,55 @@
+'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;
+} {
+ const [settings, setSettings] = useState(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 => {
+ if (!documentId) return;
+ let next = DEFAULT_DOCUMENT_SETTINGS;
+ setSettings((prev) => {
+ next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
+ ...prev,
+ schemaVersion: 1,
+ language,
+ });
+ return 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]);
+
+ return {
+ language: settings.language ?? 'auto',
+ updateLanguage,
+ };
+}
diff --git a/src/lib/client/audiobooks/pipeline.ts b/src/lib/client/audiobooks/pipeline.ts
index e9e8675..d2e7c4e 100644
--- a/src/lib/client/audiobooks/pipeline.ts
+++ b/src/lib/client/audiobooks/pipeline.ts
@@ -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 {
- 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 {
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);
}
diff --git a/src/lib/client/epub/epub-word-highlight.ts b/src/lib/client/epub/epub-word-highlight.ts
index a249b7e..de44021 100644
--- a/src/lib/client/epub/epub-word-highlight.ts
+++ b/src/lib/client/epub/epub-word-highlight.ts
@@ -1,5 +1,7 @@
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
-import type { TTSSentenceAlignment } from '@/types/tts';
+import type { TTSSentenceAlignment, TTSSentenceWord } from '@/types/tts';
+import { segmentWords } from '@/lib/shared/language';
+import { normalizeHighlightToken } from '@/lib/client/highlight-token-alignment';
export type EpubCanonicalWordToken = {
norm: string;
@@ -8,107 +10,43 @@ export type EpubCanonicalWordToken = {
};
export const normalizeWordForHighlight = (text: string): string =>
- text
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '');
+ normalizeHighlightToken(text);
-export const tokenizeCanonicalSegment = (segment: CanonicalTtsSegment): EpubCanonicalWordToken[] => {
- const tokens: EpubCanonicalWordToken[] = [];
- const wordRegex = /\S+/g;
- let match: RegExpExecArray | null;
+export const resolveAlignmentWordSourceRange = (
+ segment: CanonicalTtsSegment,
+ word: TTSSentenceWord,
+): { sourceStart: number; sourceEnd: number } | null => {
+ const { charStart, charEnd } = word;
+ if (!Number.isInteger(charStart) || !Number.isInteger(charEnd)) return null;
+ if (charStart < 0 || charEnd <= charStart || charEnd > segment.text.length) return 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;
+ return {
+ sourceStart: segment.startAnchor.offset + charStart,
+ sourceEnd: segment.startAnchor.offset + charEnd,
+ };
};
-export const buildMonotonicWordToTokenMap = (
- alignmentWords: TTSSentenceAlignment['words'],
- segmentTokens: EpubCanonicalWordToken[],
-): number[] => {
- const alignmentTokens = alignmentWords.map((word) => normalizeWordForHighlight(word.text));
- const wordToToken = new Array(alignmentWords.length).fill(-1);
- const m = alignmentTokens.length;
- const n = segmentTokens.length;
- if (!m || !n) return wordToToken;
-
- const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
- const bt: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
-
- for (let i = 1; i <= m; i += 1) {
- for (let j = 1; j <= n; j += 1) {
- let best = dp[i - 1][j - 1];
- let move = 0;
-
- const alignmentNorm = alignmentTokens[i - 1];
- const segmentNorm = segmentTokens[j - 1].norm;
- if (alignmentNorm && alignmentNorm === segmentNorm) {
- const positionPenalty =
- m <= 1 || n <= 1
- ? 0
- : Math.abs((i - 1) / (m - 1) - (j - 1) / (n - 1));
- best = dp[i - 1][j - 1] + 10 - positionPenalty;
- move = 1;
- }
-
- if (dp[i - 1][j] > best) {
- best = dp[i - 1][j];
- move = 2;
- }
- if (dp[i][j - 1] > best) {
- best = dp[i][j - 1];
- move = 3;
- }
-
- dp[i][j] = best;
- bt[i][j] = move;
- }
- }
-
- let i = m;
- let j = n;
- while (i > 0 && j > 0) {
- const move = bt[i][j];
- if (move === 1) {
- wordToToken[i - 1] = j - 1;
- i -= 1;
- j -= 1;
- } else if (move === 2) {
- i -= 1;
- } else if (move === 3) {
- j -= 1;
- } else {
- i -= 1;
- j -= 1;
- }
- }
-
- return wordToToken;
-};
+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 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('::');
diff --git a/src/lib/client/highlight-token-alignment.ts b/src/lib/client/highlight-token-alignment.ts
new file mode 100644
index 0000000..dd560bf
--- /dev/null
+++ b/src/lib/client/highlight-token-alignment.ts
@@ -0,0 +1,255 @@
+import { CmpStr } from 'cmpstr';
+
+import { normalizeUnicodeToken } from '@/lib/shared/language';
+import type { TTSSentenceAlignment } from '@/types/tts';
+
+const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
+
+export interface HighlightTokenRange {
+ start: number;
+ end: number;
+}
+
+export interface HighlightTokenMatchResult extends HighlightTokenRange {
+ rating: number;
+ lengthDiff: number;
+}
+
+export function normalizeHighlightToken(text: string): string {
+ return normalizeUnicodeToken(text);
+}
+
+export function findBestHighlightTokenMatch(
+ patternTokens: string[],
+ tokenTexts: string[],
+): HighlightTokenMatchResult {
+ const normalizedPattern = patternTokens.map(normalizeHighlightToken).filter(Boolean);
+ const normalizedTargets = tokenTexts.map(normalizeHighlightToken);
+ const cleanPattern = normalizedPattern.join('');
+ const patternLen = cleanPattern.length;
+ const responseBase: HighlightTokenMatchResult = {
+ start: -1,
+ end: -1,
+ rating: 0,
+ lengthDiff: Number.POSITIVE_INFINITY,
+ };
+
+ if (!patternLen || !normalizedTargets.length) return responseBase;
+
+ // Most viewer highlights are exact token sequences. Resolve those in one
+ // linear pass before entering the fuzzy window search, which is expensive
+ // enough to stall the UI when the target is a full HTML/TXT/MD document.
+ const firstPatternToken = normalizedPattern[0];
+ for (let start = 0; start < normalizedTargets.length; start += 1) {
+ if (normalizedTargets[start] !== firstPatternToken) continue;
+ let matches = true;
+ for (let offset = 1; offset < normalizedPattern.length; offset += 1) {
+ if (normalizedTargets[start + offset] !== normalizedPattern[offset]) {
+ matches = false;
+ break;
+ }
+ }
+ if (matches) {
+ return {
+ start,
+ end: start + normalizedPattern.length - 1,
+ rating: 1,
+ lengthDiff: 0,
+ };
+ }
+ }
+
+ const patternTokenCount = normalizedPattern.length;
+ const minWindowTokens = Math.max(1, Math.floor(patternTokenCount * 0.6));
+ const maxWindowTokens = Math.max(
+ minWindowTokens,
+ Math.ceil(patternTokenCount * 1.4),
+ );
+
+ let bestStart = -1;
+ let bestEnd = -1;
+ let bestRating = 0;
+ let bestLengthDiff = Number.POSITIVE_INFINITY;
+
+ for (let start = 0; start < normalizedTargets.length; start += 1) {
+ let combined = '';
+
+ for (
+ let offset = 0;
+ offset < maxWindowTokens && start + offset < normalizedTargets.length;
+ offset += 1
+ ) {
+ combined += normalizedTargets[start + offset];
+
+ const windowSize = offset + 1;
+ if (windowSize < minWindowTokens) continue;
+ if (combined.length > patternLen * 2) break;
+
+ const similarity = cmp.compare(combined, cleanPattern);
+ const lengthDiff = Math.abs(combined.length - patternLen);
+ const lengthPenalty = lengthDiff / patternLen;
+ const adjustedRating = similarity * (1 - lengthPenalty * 0.3);
+
+ let boostedRating = adjustedRating;
+ const maxPrefixCheck = Math.min(windowSize, normalizedPattern.length, 5);
+ let prefixMatches = 0;
+ for (let i = 0; i < maxPrefixCheck; i += 1) {
+ const tokenSim = cmp.compare(normalizedTargets[start + i], normalizedPattern[i]);
+ if (tokenSim < 0.8) break;
+ prefixMatches += 1;
+ }
+
+ if (prefixMatches > 0) {
+ boostedRating = adjustedRating * (1 + (prefixMatches / maxPrefixCheck) * 0.25);
+ }
+
+ if (
+ boostedRating > bestRating
+ || (
+ Math.abs(boostedRating - bestRating) < 1e-3
+ && lengthDiff < bestLengthDiff
+ )
+ ) {
+ bestRating = boostedRating;
+ bestLengthDiff = lengthDiff;
+ bestStart = start;
+ bestEnd = start + offset;
+ }
+ }
+ }
+
+ return {
+ start: bestStart,
+ end: bestEnd,
+ rating: bestRating,
+ lengthDiff: bestLengthDiff,
+ };
+}
+
+function buildExactConcatenatedRanges(
+ alignmentNorms: string[],
+ targetNorms: string[],
+): Array | null {
+ if (alignmentNorms.join('') !== targetNorms.join('')) return null;
+
+ const targetOffsets: HighlightTokenRange[] = [];
+ let targetCursor = 0;
+ for (const norm of targetNorms) {
+ targetOffsets.push({ start: targetCursor, end: targetCursor + norm.length });
+ targetCursor += norm.length;
+ }
+
+ const ranges: Array = [];
+ let alignmentCursor = 0;
+ for (const norm of alignmentNorms) {
+ const alignmentStart = alignmentCursor;
+ const alignmentEnd = alignmentStart + norm.length;
+ alignmentCursor = alignmentEnd;
+
+ let first = -1;
+ let last = -1;
+ for (let i = 0; i < targetOffsets.length; i += 1) {
+ const target = targetOffsets[i];
+ if (target.end <= alignmentStart) continue;
+ if (target.start >= alignmentEnd) break;
+ if (first === -1) first = i;
+ last = i;
+ }
+ ranges.push(first === -1 ? null : { start: first, end: last });
+ }
+
+ return ranges;
+}
+
+export function buildAlignmentTokenRanges(
+ alignmentWords: TTSSentenceAlignment['words'],
+ targetTexts: string[],
+ options: { fillGaps?: boolean; minimumSimilarity?: number } = {},
+): Array {
+ const alignmentNorms = alignmentWords.map((word) => normalizeHighlightToken(word.text));
+ const targetNorms = targetTexts.map(normalizeHighlightToken);
+ const ranges = new Array(alignmentWords.length).fill(null);
+
+ const exactRanges = buildExactConcatenatedRanges(alignmentNorms, targetNorms);
+ if (exactRanges) return exactRanges;
+
+ const targets = targetNorms
+ .map((norm, tokenIndex) => ({ norm, tokenIndex }))
+ .filter((token) => Boolean(token.norm));
+ const alignments = alignmentNorms
+ .map((norm, wordIndex) => ({ norm, wordIndex }))
+ .filter((word) => Boolean(word.norm));
+ const m = targets.length;
+ const n = alignments.length;
+ if (!m || !n) return ranges;
+
+ const dp: number[][] = Array.from({ length: m + 1 }, () =>
+ new Array(n + 1).fill(Number.POSITIVE_INFINITY),
+ );
+ const bt: number[][] = Array.from({ length: m + 1 }, () =>
+ new Array(n + 1).fill(0),
+ );
+ dp[0][0] = 0;
+ const gapCost = 0.7;
+
+ for (let i = 0; i <= m; i += 1) {
+ for (let j = 0; j <= n; j += 1) {
+ if (i > 0 && j > 0) {
+ const a = targets[i - 1].norm;
+ const b = alignments[j - 1].norm;
+ const similarity = a === b ? 1 : cmp.compare(a, b);
+ const candidate = dp[i - 1][j - 1] + (1 - similarity);
+ if (candidate < dp[i][j]) {
+ dp[i][j] = candidate;
+ bt[i][j] = 0;
+ }
+ }
+ if (i > 0 && dp[i - 1][j] + gapCost < dp[i][j]) {
+ dp[i][j] = dp[i - 1][j] + gapCost;
+ bt[i][j] = 1;
+ }
+ if (j > 0 && dp[i][j - 1] + gapCost < dp[i][j]) {
+ dp[i][j] = dp[i][j - 1] + gapCost;
+ bt[i][j] = 2;
+ }
+ }
+ }
+
+ let i = m;
+ let j = n;
+ while (i > 0 || j > 0) {
+ const move = bt[i][j];
+ if (i > 0 && j > 0 && move === 0) {
+ const tokenIndex = targets[i - 1].tokenIndex;
+ const a = targets[i - 1].norm;
+ const b = alignments[j - 1].norm;
+ const similarity = a === b ? 1 : cmp.compare(a, b);
+ if (similarity >= (options.minimumSimilarity ?? 0)) {
+ ranges[alignments[j - 1].wordIndex] = { start: tokenIndex, end: tokenIndex };
+ }
+ i -= 1;
+ j -= 1;
+ } else if (i > 0 && (move === 1 || j === 0)) {
+ i -= 1;
+ } else if (j > 0 && (move === 2 || i === 0)) {
+ j -= 1;
+ } else {
+ break;
+ }
+ }
+
+ if (!options.fillGaps) return ranges;
+
+ let lastSeen: HighlightTokenRange | null = null;
+ for (let k = 0; k < ranges.length; k += 1) {
+ if (ranges[k]) lastSeen = ranges[k];
+ else if (lastSeen) ranges[k] = lastSeen;
+ }
+ let nextSeen: HighlightTokenRange | null = null;
+ for (let k = ranges.length - 1; k >= 0; k -= 1) {
+ if (ranges[k]) nextSeen = ranges[k];
+ else if (nextSeen) ranges[k] = nextSeen;
+ }
+
+ return ranges;
+}
diff --git a/src/lib/client/html/highlight.ts b/src/lib/client/html/highlight.ts
index 104765d..a78a70a 100644
--- a/src/lib/client/html/highlight.ts
+++ b/src/lib/client/html/highlight.ts
@@ -11,13 +11,18 @@
* sentence
* - `WORD` — saturated background on the currently-spoken word
*
- * Word-to-DOM alignment is done via Needleman-Wunsch (same approach the PDF
- * reader uses) so DOM token counts that diverge from whisper's word count
- * still produce a smooth, monotonic word highlight rather than a proportional
- * approximation that snaps around when the counts disagree.
+ * Word-to-DOM alignment uses the shared viewer token-range mapper so DOM token
+ * counts that diverge from the timed alignment still produce a smooth,
+ * monotonic highlight across languages.
*/
-import { CmpStr } from 'cmpstr';
import type { TTSSentenceAlignment } from '@/types/tts';
+import { segmentWords } from '@/lib/shared/language';
+import {
+ buildAlignmentTokenRanges,
+ findBestHighlightTokenMatch,
+ normalizeHighlightToken,
+ type HighlightTokenRange,
+} from '@/lib/client/highlight-token-alignment';
export const HTML_SENTENCE_CLASS = 'openreader-html-highlight-sentence';
export const HTML_WORD_CLASS = 'openreader-html-highlight-word';
@@ -29,8 +34,6 @@ interface DomToken {
norm: string;
}
-const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
-
let sentenceWraps: HTMLSpanElement[] = [];
let wordWraps: HTMLSpanElement[] = [];
@@ -45,29 +48,19 @@ interface SentenceState {
// is in place. Stable across word wrap/unwrap cycles because clear() calls
// `parent.normalize()` which restores the original text-node structure.
wordTokens: DomToken[];
- // For an alignment we've already seen, the cached wordIndex → tokenIndex map.
+ // For an alignment we've already seen, the cached wordIndex → token range map.
alignment: TTSSentenceAlignment | null;
- wordToToken: number[] | null;
+ wordToTokenRange: Array | null;
}
let sentenceState: SentenceState | null = null;
function normalizeWord(word: string): string {
- return word
- .toLowerCase()
- .replace(/[\p{P}\p{S}]+/gu, '')
- .trim();
+ return normalizeHighlightToken(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 +101,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 +128,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 +148,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 +156,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,
});
}
@@ -180,43 +173,9 @@ function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
}
function findBestWindow(tokens: DomToken[], patternTokens: string[]): { start: number; end: number } | null {
- if (!tokens.length || !patternTokens.length) return null;
- const pLen = patternTokens.length;
-
- let bestStart = -1;
- let bestEnd = -1;
- let bestScore = 0;
-
- for (let i = 0; i + Math.max(1, Math.ceil(pLen * 0.5)) - 1 < tokens.length; i += 1) {
- if (tokens[i].norm !== patternTokens[0]) continue;
- let matches = 1;
- let domCursor = i + 1;
- for (let p = 1; p < pLen && domCursor < tokens.length; p += 1) {
- let stepped = false;
- for (let k = 0; k < 3 && domCursor + k < tokens.length; k += 1) {
- if (tokens[domCursor + k].norm === patternTokens[p]) {
- matches += 1;
- domCursor += k + 1;
- stepped = true;
- break;
- }
- }
- if (!stepped) domCursor += 1;
- }
- const end = Math.min(tokens.length - 1, domCursor - 1);
- const score = matches / pLen;
- if (score > bestScore) {
- bestScore = score;
- bestStart = i;
- bestEnd = end;
- if (score >= 0.95) break;
- }
- }
-
- if (bestScore >= 0.5 && bestStart !== -1) {
- return { start: bestStart, end: bestEnd };
- }
- return null;
+ const match = findBestHighlightTokenMatch(patternTokens, tokens.map((token) => token.norm));
+ if (match.start === -1 || match.rating < 0.5) return null;
+ return { start: match.start, end: match.end };
}
function wrapTokenRange(tokens: DomToken[], start: number, end: number, className: string): HTMLSpanElement[] {
@@ -260,14 +219,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,118 +240,13 @@ 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,
+ wordToTokenRange: null,
};
return true;
}
-/**
- * Build a wordIndex → tokenIndex map via Needleman-Wunsch alignment between
- * whisper's word list and the DOM tokens inside the sentence. Mirrors the
- * approach in `src/lib/client/pdf.ts#highlightWordIndex` so PDF and HTML
- * highlights behave the same way under count mismatches (contractions,
- * stripped punctuation, missing whitespace, etc.).
- */
-function buildAlignmentMap(
- alignment: TTSSentenceAlignment,
- domTokens: DomToken[],
-): number[] {
- const words = alignment.words || [];
- const wordToToken = new Array(words.length).fill(-1);
-
- const domFiltered: { tokenIndex: number; norm: string }[] = [];
- for (let i = 0; i < domTokens.length; i += 1) {
- const norm = domTokens[i].norm;
- if (norm) domFiltered.push({ tokenIndex: i, norm });
- }
-
- const ttsFiltered: { wordIndex: number; norm: string }[] = [];
- for (let i = 0; i < words.length; i += 1) {
- const norm = normalizeWord(words[i].text);
- if (norm) ttsFiltered.push({ wordIndex: i, norm });
- }
-
- const m = domFiltered.length;
- const n = ttsFiltered.length;
- if (!m || !n) return wordToToken;
-
- const dp: number[][] = Array.from({ length: m + 1 }, () =>
- new Array(n + 1).fill(Number.POSITIVE_INFINITY),
- );
- const bt: number[][] = Array.from({ length: m + 1 }, () =>
- new Array(n + 1).fill(0),
- ); // 0=diag (substitute), 1=up (skip dom), 2=left (skip tts)
-
- dp[0][0] = 0;
- const GAP_COST = 0.7;
-
- for (let i = 0; i <= m; i += 1) {
- for (let j = 0; j <= n; j += 1) {
- if (i > 0 && j > 0) {
- const a = domFiltered[i - 1].norm;
- const b = ttsFiltered[j - 1].norm;
- const sim = a === b ? 1 : cmp.compare(a, b);
- const cand = dp[i - 1][j - 1] + (1 - sim);
- if (cand < dp[i][j]) {
- dp[i][j] = cand;
- bt[i][j] = 0;
- }
- }
- if (i > 0) {
- const cand = dp[i - 1][j] + GAP_COST;
- if (cand < dp[i][j]) {
- dp[i][j] = cand;
- bt[i][j] = 1;
- }
- }
- if (j > 0) {
- const cand = dp[i][j - 1] + GAP_COST;
- if (cand < dp[i][j]) {
- dp[i][j] = cand;
- bt[i][j] = 2;
- }
- }
- }
- }
-
- let i = m;
- let j = n;
- while (i > 0 || j > 0) {
- const move = bt[i][j];
- if (i > 0 && j > 0 && move === 0) {
- const domIdx = domFiltered[i - 1].tokenIndex;
- const ttsIdx = ttsFiltered[j - 1].wordIndex;
- if (wordToToken[ttsIdx] === -1) wordToToken[ttsIdx] = domIdx;
- i -= 1;
- j -= 1;
- } else if (i > 0 && (move === 1 || j === 0)) {
- i -= 1;
- } else if (j > 0 && (move === 2 || i === 0)) {
- j -= 1;
- } else {
- break;
- }
- }
-
- // Forward-fill, then backward-fill, so every wordIndex has a nearest known
- // DOM token. This keeps the word highlight stable when whisper emits a
- // word that didn't survive normalization (e.g. an apostrophe-only token).
- let lastSeen = -1;
- for (let k = 0; k < wordToToken.length; k += 1) {
- if (wordToToken[k] !== -1) lastSeen = wordToToken[k];
- else if (lastSeen !== -1) wordToToken[k] = lastSeen;
- }
- let nextSeen = -1;
- for (let k = wordToToken.length - 1; k >= 0; k -= 1) {
- if (wordToToken[k] !== -1) nextSeen = wordToToken[k];
- else if (nextSeen !== -1) wordToToken[k] = nextSeen;
- }
-
- return wordToToken;
-}
-
export function highlightHtmlWord(
container: HTMLElement | null | undefined,
alignment: TTSSentenceAlignment | undefined,
@@ -411,16 +266,20 @@ export function highlightHtmlWord(
if (!words.length || wordIndex >= words.length) return false;
// (Re)build the alignment map when this is a new alignment object.
- if (sentenceState.alignment !== alignment || !sentenceState.wordToToken) {
+ if (sentenceState.alignment !== alignment || !sentenceState.wordToTokenRange) {
sentenceState.alignment = alignment;
- sentenceState.wordToToken = buildAlignmentMap(alignment, sentenceState.wordTokens);
+ sentenceState.wordToTokenRange = buildAlignmentTokenRanges(
+ alignment.words,
+ sentenceState.wordTokens.map((token) => token.norm),
+ { fillGaps: true },
+ );
}
- const tokenIndex = sentenceState.wordToToken[wordIndex];
- if (tokenIndex === undefined || tokenIndex < 0) return false;
- if (tokenIndex >= sentenceState.wordTokens.length) return false;
+ const tokenRange = sentenceState.wordToTokenRange[wordIndex];
+ if (!tokenRange) return false;
+ if (tokenRange.start < 0 || tokenRange.end >= sentenceState.wordTokens.length) return false;
- wordWraps = wrapTokenRange(sentenceState.wordTokens, tokenIndex, tokenIndex, HTML_WORD_CLASS);
+ wordWraps = wrapTokenRange(sentenceState.wordTokens, tokenRange.start, tokenRange.end, HTML_WORD_CLASS);
return wordWraps.length > 0;
}
diff --git a/src/lib/client/pdf-highlight-worker.ts b/src/lib/client/pdf-highlight-worker.ts
index 5b41d45..fa3fbd6 100644
--- a/src/lib/client/pdf-highlight-worker.ts
+++ b/src/lib/client/pdf-highlight-worker.ts
@@ -1,13 +1,11 @@
///
-import { CmpStr } from 'cmpstr';
-
-const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
+import { findBestHighlightTokenMatch } from './highlight-token-alignment';
interface TokenMatchRequest {
id: string;
type: 'tokenMatch';
- pattern: string;
+ patternTokens: string[];
tokenTexts: string[];
}
@@ -39,112 +37,17 @@ self.onmessage = (event: MessageEvent) => {
const data = event.data;
if (!data || data.type !== 'tokenMatch') return;
- const { id, pattern, tokenTexts } = data;
-
- const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
- const patternLen = cleanPattern.length;
-
- const responseBase: TokenMatchResponse = {
- id,
- type: 'tokenMatchResult',
- bestStart: -1,
- bestEnd: -1,
- rating: 0,
- lengthDiff: Number.POSITIVE_INFINITY,
- };
-
- if (!patternLen || !tokenTexts.length) {
- (self as unknown as DedicatedWorkerGlobalScope).postMessage(responseBase);
- return;
- }
-
- const patternTokens = cleanPattern.split(' ').filter(Boolean);
- const patternTokenCount = patternTokens.length || 1;
-
- const minWindowTokens = Math.max(1, Math.floor(patternTokenCount * 0.6));
- const maxWindowTokens = Math.max(
- minWindowTokens,
- Math.ceil(patternTokenCount * 1.4)
- );
-
- let bestStart = -1;
- let bestEnd = -1;
- let bestRating = 0;
- let bestLengthDiff = Number.POSITIVE_INFINITY;
-
- for (let start = 0; start < tokenTexts.length; start++) {
- let combined = '';
-
- for (
- let offset = 0;
- offset < maxWindowTokens && start + offset < tokenTexts.length;
- offset++
- ) {
- const token = tokenTexts[start + offset];
- combined = combined ? `${combined} ${token}` : token;
-
- const windowSize = offset + 1;
- if (windowSize < minWindowTokens) continue;
- if (combined.length > patternLen * 2) break;
-
- const similarity = cmp.compare(combined, cleanPattern);
- const lengthDiff = Math.abs(combined.length - patternLen);
- const lengthPenalty = lengthDiff / patternLen;
- const adjustedRating = similarity * (1 - lengthPenalty * 0.3);
-
- // Prefix-alignment boost:
- // Favour windows whose first few tokens closely match the beginning
- // of the pattern, so we are less likely to cut off the first 1–2 words.
- let boostedRating = adjustedRating;
- if (patternTokens.length > 0) {
- const windowTokens = tokenTexts.slice(start, start + windowSize);
- const maxPrefixCheck = Math.min(
- windowTokens.length,
- patternTokens.length,
- 5
- );
-
- let prefixMatches = 0;
- for (let i = 0; i < maxPrefixCheck; i++) {
- const tokenText = windowTokens[i];
- const patternToken = patternTokens[i];
- // Require reasonably strong similarity for a prefix match
- const tokenSim = cmp.compare(tokenText, patternToken);
- if (tokenSim >= 0.8) {
- prefixMatches++;
- } else {
- // Stop at the first non-matching leading token
- break;
- }
- }
-
- if (prefixMatches > 0) {
- const prefixRatio = prefixMatches / maxPrefixCheck;
- const PREFIX_BOOST_FACTOR = 0.25; // up to +25% boost
- boostedRating = adjustedRating * (1 + prefixRatio * PREFIX_BOOST_FACTOR);
- }
- }
-
- if (
- boostedRating > bestRating ||
- (Math.abs(boostedRating - bestRating) < 1e-3 &&
- lengthDiff < bestLengthDiff)
- ) {
- bestRating = boostedRating;
- bestLengthDiff = lengthDiff;
- bestStart = start;
- bestEnd = start + offset;
- }
- }
- }
+ const { id, patternTokens, tokenTexts } = data;
+ const result = findBestHighlightTokenMatch(patternTokens, tokenTexts);
const response: TokenMatchResponse = {
- ...responseBase,
- bestStart,
- bestEnd,
- rating: bestRating,
- lengthDiff: bestLengthDiff,
+ id,
+ type: 'tokenMatchResult',
+ bestStart: result.start,
+ bestEnd: result.end,
+ rating: result.rating,
+ lengthDiff: result.lengthDiff,
};
(self as unknown as DedicatedWorkerGlobalScope).postMessage(response);
-};
\ No newline at end of file
+};
diff --git a/src/lib/client/pdf.ts b/src/lib/client/pdf.ts
index 9727d4b..1293957 100644
--- a/src/lib/client/pdf.ts
+++ b/src/lib/client/pdf.ts
@@ -3,16 +3,18 @@ import { TextLayer } from 'pdfjs-dist';
import "core-js/proposals/promise-with-resolvers";
import type { TTSSentenceAlignment } from '@/types/tts';
import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf';
-import { CmpStr } from 'cmpstr';
import type { TTSSegmentLocator } from '@/types/client';
-
-const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
+import { segmentWords } from '@/lib/shared/language';
+import {
+ buildAlignmentTokenRanges,
+ type HighlightTokenRange,
+} from '@/lib/client/highlight-token-alignment';
// Worker coordination for offloading highlight token matching
interface HighlightTokenMatchRequest {
id: string;
type: 'tokenMatch';
- pattern: string;
+ patternTokens: string[];
tokenTexts: string[];
}
@@ -45,7 +47,7 @@ function getHighlightWorker(): Worker | null {
}
function runHighlightTokenMatch(
- pattern: string,
+ patternTokens: string[],
tokenTexts: string[]
): Promise {
const worker = getHighlightWorker();
@@ -70,7 +72,7 @@ function runHighlightTokenMatch(
const message: HighlightTokenMatchRequest = {
id,
type: 'tokenMatch',
- pattern,
+ patternTokens,
tokenTexts,
};
worker.postMessage(message);
@@ -151,7 +153,7 @@ let lastSpanNodes: HTMLElement[] = [];
let lastTokens: PDFToken[] = [];
let lastSentenceTokenWindow: { start: number; end: number } | null = null;
let lastSentencePattern: string | null = null;
-let lastSentenceWordToTokenMap: number[] | null = null;
+let lastSentenceWordToTokenRangeMap: Array | null = null;
function getOrCreateHighlightLayer(span: HTMLElement): {
layer: HTMLElement;
@@ -180,12 +182,6 @@ function getOrCreateHighlightLayer(span: HTMLElement): {
return { layer, pageElement, pageRect: pageElement.getBoundingClientRect() };
}
-const normalizeWordForMatch = (text: string): string =>
- text
- .trim()
- .replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '')
- .toLowerCase();
-
// Highlighting functions
let highlightPatternSeq = 0;
@@ -193,6 +189,7 @@ type HighlightPatternOptions = {
parsedDocument?: ParsedPdfDocument | null;
locator?: TTSSegmentLocator | null;
useBlockGeometryOnly?: boolean;
+ language?: string;
};
function getHighlightLayerForPage(pageElement: HTMLElement): {
@@ -422,7 +419,7 @@ export function highlightPattern(
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
if (!cleanPattern) return;
lastSentencePattern = cleanPattern;
- lastSentenceWordToTokenMap = null;
+ lastSentenceWordToTokenRangeMap = null;
lastSentenceTokenWindow = null;
const parsedDocument = options?.parsedDocument ?? null;
const locator = options?.locator ?? null;
@@ -451,17 +448,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,
});
}
});
@@ -617,9 +610,10 @@ export function highlightPattern(
};
const tokenTexts = tokens.map((t) => t.text);
+ const patternTokens = segmentWords(cleanPattern, options?.language).map((token) => token.text);
// Fire-and-forget async worker call; UI thread returns immediately
- runHighlightTokenMatch(cleanPattern, tokenTexts)
+ runHighlightTokenMatch(patternTokens, tokenTexts)
.then((result) => {
if (seq !== highlightPatternSeq) return;
if (!result || result.bestStart === -1) {
@@ -676,158 +670,61 @@ export function highlightWordIndex(
const end = lastSentenceTokenWindow.end;
if (end < start) return;
- // Lazily build or refresh the mapping from alignment word
- // indices to PDF token indices for this sentence window.
+ // Lazily build or refresh the shared mapping from alignment words to PDF
+ // token ranges for this sentence window.
if (
- !lastSentenceWordToTokenMap ||
- lastSentenceWordToTokenMap.length !== words.length
+ !lastSentenceWordToTokenRangeMap ||
+ lastSentenceWordToTokenRangeMap.length !== words.length
) {
- const pdfFiltered: { tokenIndex: number; norm: string }[] = [];
- for (let i = start; i <= end; i++) {
- const norm = normalizeWordForMatch(lastTokens[i].text);
- if (!norm) continue;
- pdfFiltered.push({ tokenIndex: i, norm });
- }
-
- const ttsFiltered: { wordIndex: number; norm: string }[] = [];
- for (let i = 0; i < words.length; i++) {
- const norm = normalizeWordForMatch(words[i].text);
- if (!norm) continue;
- ttsFiltered.push({ wordIndex: i, norm });
- }
-
- const wordToToken = new Array(words.length).fill(-1);
-
- const m = pdfFiltered.length;
- const n = ttsFiltered.length;
-
- if (m && n) {
- const dp: number[][] = Array.from({ length: m + 1 }, () =>
- new Array(n + 1).fill(Number.POSITIVE_INFINITY)
- );
- const bt: number[][] = Array.from({ length: m + 1 }, () =>
- new Array(n + 1).fill(0)
- ); // 0=diag,1=up,2=left
-
- dp[0][0] = 0;
- const GAP_COST = 0.7;
-
- for (let i = 0; i <= m; i++) {
- for (let j = 0; j <= n; j++) {
- if (i > 0 && j > 0) {
- const a = pdfFiltered[i - 1].norm;
- const b = ttsFiltered[j - 1].norm;
- const sim = cmp.compare(a, b);
- const subCost = 1 - sim;
- const cand = dp[i - 1][j - 1] + subCost;
- if (cand < dp[i][j]) {
- dp[i][j] = cand;
- bt[i][j] = 0;
- }
- }
- if (i > 0) {
- const cand = dp[i - 1][j] + GAP_COST;
- if (cand < dp[i][j]) {
- dp[i][j] = cand;
- bt[i][j] = 1;
- }
- }
- if (j > 0) {
- const cand = dp[i][j - 1] + GAP_COST;
- if (cand < dp[i][j]) {
- dp[i][j] = cand;
- bt[i][j] = 2;
- }
- }
- }
- }
-
- let i = m;
- let j = n;
- while (i > 0 || j > 0) {
- const move = bt[i][j];
- if (i > 0 && j > 0 && move === 0) {
- const pdfIdx = pdfFiltered[i - 1].tokenIndex;
- const ttsIdx = ttsFiltered[j - 1].wordIndex;
- if (wordToToken[ttsIdx] === -1) {
- wordToToken[ttsIdx] = pdfIdx;
- }
- i -= 1;
- j -= 1;
- } else if (i > 0 && (move === 1 || j === 0)) {
- i -= 1;
- } else if (j > 0 && (move === 2 || i === 0)) {
- j -= 1;
- } else {
- break;
- }
- }
-
- // Propagate nearest known mapping to fill gaps
- let lastSeen = -1;
- for (let k = 0; k < wordToToken.length; k++) {
- if (wordToToken[k] !== -1) {
- lastSeen = wordToToken[k];
- } else if (lastSeen !== -1) {
- wordToToken[k] = lastSeen;
- }
- }
- let nextSeen = -1;
- for (let k = wordToToken.length - 1; k >= 0; k--) {
- if (wordToToken[k] !== -1) {
- nextSeen = wordToToken[k];
- } else if (nextSeen !== -1) {
- wordToToken[k] = nextSeen;
- }
- }
- }
-
- lastSentenceWordToTokenMap = wordToToken;
+ const relativeRanges = buildAlignmentTokenRanges(
+ words,
+ lastTokens.slice(start, end + 1).map((token) => token.text),
+ { fillGaps: true },
+ );
+ lastSentenceWordToTokenRangeMap = relativeRanges.map((range) => (
+ range ? { start: range.start + start, end: range.end + start } : null
+ ));
}
- const mappedIndex =
- lastSentenceWordToTokenMap && wordIndex < lastSentenceWordToTokenMap.length
- ? lastSentenceWordToTokenMap[wordIndex]
- : -1;
+ const tokenRange = lastSentenceWordToTokenRangeMap[wordIndex];
+ if (!tokenRange) return;
- if (mappedIndex === -1) return;
+ for (let tokenIndex = tokenRange.start; tokenIndex <= tokenRange.end; tokenIndex += 1) {
+ const token = lastTokens[tokenIndex];
+ const span = lastSpanNodes[token.spanIndex];
+ if (!span) continue;
- const chosenTokenIndex = mappedIndex;
+ const node = token.textNode;
+ if (!node || node.nodeType !== Node.TEXT_NODE) continue;
- const token = lastTokens[chosenTokenIndex];
- const span = lastSpanNodes[token.spanIndex];
- if (!span) return;
+ try {
+ const range = document.createRange();
+ range.setStart(node, token.startOffset);
+ range.setEnd(node, token.endOffset);
- const node = token.textNode;
- if (!node || node.nodeType !== Node.TEXT_NODE) return;
+ const highlightTarget = getOrCreateHighlightLayer(span);
+ if (!highlightTarget) continue;
- try {
- const range = document.createRange();
- range.setStart(node, token.startOffset);
- range.setEnd(node, token.endOffset);
+ const { layer: highlightLayer, pageRect } = highlightTarget;
+ const rects = Array.from(range.getClientRects());
- const highlightTarget = getOrCreateHighlightLayer(span);
- if (!highlightTarget) return;
-
- const { layer: highlightLayer, pageRect } = highlightTarget;
- const rects = Array.from(range.getClientRects());
-
- rects.forEach((rect) => {
- const highlight = document.createElement('div');
- highlight.className = 'pdf-word-highlight-overlay';
- highlight.style.position = 'absolute';
- highlight.style.backgroundColor = 'var(--accent)';
- highlight.style.opacity = '0.4';
- highlight.style.pointerEvents = 'none';
- highlight.style.left = `${rect.left - pageRect.left}px`;
- highlight.style.top = `${rect.top - pageRect.top}px`;
- highlight.style.width = `${rect.width}px`;
- highlight.style.height = `${rect.height}px`;
- highlight.style.zIndex = '2';
- highlightLayer.appendChild(highlight);
- });
- } catch {
- // Ignore range errors
+ rects.forEach((rect) => {
+ const highlight = document.createElement('div');
+ highlight.className = 'pdf-word-highlight-overlay';
+ highlight.style.position = 'absolute';
+ highlight.style.backgroundColor = 'var(--accent)';
+ highlight.style.opacity = '0.4';
+ highlight.style.pointerEvents = 'none';
+ highlight.style.left = `${rect.left - pageRect.left}px`;
+ highlight.style.top = `${rect.top - pageRect.top}px`;
+ highlight.style.width = `${rect.width}px`;
+ highlight.style.height = `${rect.height}px`;
+ highlight.style.zIndex = '2';
+ highlightLayer.appendChild(highlight);
+ });
+ } catch {
+ // Ignore range errors
+ }
}
}
diff --git a/src/lib/server/audiobooks/settings.ts b/src/lib/server/audiobooks/settings.ts
index cf74673..cdef976 100644
--- a/src/lib/server/audiobooks/settings.ts
+++ b/src/lib/server/audiobooks/settings.ts
@@ -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 =
diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts
index 01a7978..cfca3d3 100644
--- a/src/lib/server/tts/generate.ts
+++ b/src/lib/server/tts/generate.ts
@@ -6,12 +6,16 @@ import {
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
} from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
-import { resolveReplicateVoiceInputKey } from '@/lib/server/tts/voice-resolution';
+import {
+ resolveReplicateLanguageInput,
+ resolveReplicateVoiceInputKey,
+} from '@/lib/server/tts/voice-resolution';
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
import { LRUCache } from 'lru-cache';
import { createHash } from 'crypto';
import { access, readFile } from 'fs/promises';
import { resolve } from 'path';
+import { getLanguageDisplayName, toBaseLanguageCode } from '@/lib/shared/language';
export interface ServerTTSRequest {
text: string;
@@ -20,6 +24,7 @@ export interface ServerTTSRequest {
format?: string;
model?: string | null;
instructions?: string;
+ language?: string;
provider: string;
apiKey: string;
baseUrl?: string;
@@ -30,6 +35,7 @@ type CustomVoice = string;
type ExtendedSpeechParams = Omit & {
voice: SpeechCreateParams['voice'] | CustomVoice;
instructions?: string;
+ language?: string;
};
type ResolvedServerTTSRequest = {
@@ -39,6 +45,7 @@ type ResolvedServerTTSRequest = {
format: string;
model: SpeechCreateParams['model'];
instructions?: string;
+ language?: string;
provider: string;
apiKey: string;
baseUrl?: string;
@@ -55,6 +62,7 @@ const REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES = 512;
const replicateBlockedUntilByScope = new LRUCache({
max: REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES,
});
+const openAiCompatibleLanguageUnsupported = new LRUCache({ max: 256 });
const DEFAULT_TTS_CACHE_MAX_SIZE_BYTES = 256 * 1024 * 1024;
const DEFAULT_TTS_CACHE_TTL_MS = 1000 * 60 * 30;
@@ -323,6 +331,7 @@ function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
format,
model,
instructions,
+ language: input.language,
provider,
apiKey: input.apiKey,
baseUrl: input.baseUrl,
@@ -338,6 +347,7 @@ function makeCacheKey(input: {
format: string;
text: string;
instructions?: string;
+ language?: string;
testNamespace?: string | null;
}) {
const canonical = {
@@ -348,6 +358,7 @@ function makeCacheKey(input: {
format: input.format,
text: input.text,
instructions: input.instructions || undefined,
+ language: input.language || undefined,
testNamespace: input.testNamespace || undefined,
};
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
@@ -363,6 +374,7 @@ export function buildTTSCacheKey(request: ServerTTSRequest): string {
format: resolved.format,
text: resolved.text,
instructions: resolved.instructions,
+ language: resolved.language,
testNamespace: resolved.testNamespace,
});
}
@@ -433,7 +445,7 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise = { text: request.text };
@@ -498,9 +510,46 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise,
+ request: ResolvedServerTTSRequest,
+): Promise> {
+ if (!request.language) return input;
+ const languageInput = await resolveReplicateLanguageInput({
+ provider: 'replicate',
+ model: request.model as string,
+ apiKey: request.apiKey,
+ });
+ if (languageInput) {
+ const resolvedValue = resolveReplicateLanguageValue(request.language, languageInput.allowedValues);
+ if (resolvedValue) {
+ input[languageInput.key] = resolvedValue;
+ }
+ }
return input;
}
+export function resolveReplicateLanguageValue(language: string, allowedValues: string[]): string | null {
+ if (allowedValues.length === 0) return language;
+
+ const baseLanguage = toBaseLanguageCode(language);
+ const candidates = [
+ language,
+ baseLanguage,
+ getLanguageDisplayName(language),
+ getLanguageDisplayName(baseLanguage),
+ ];
+ const allowedByLowercase = new Map(
+ allowedValues.map((value) => [value.toLocaleLowerCase(), value]),
+ );
+ return candidates
+ .map((candidate) => allowedByLowercase.get(candidate.toLocaleLowerCase()))
+ .find((candidate): candidate is string => Boolean(candidate)) ?? null;
+}
+
async function runReplicateRequest(
request: ResolvedServerTTSRequest,
signal: AbortSignal,
@@ -594,6 +643,31 @@ async function runProviderRequest(
createParams.instructions = request.instructions;
}
+ if (request.provider !== 'openai' && request.language) {
+ const supportKey = `${request.provider}|${request.baseUrl || ''}|${request.model as string}|${request.language}`;
+ if (!openAiCompatibleLanguageUnsupported.has(supportKey)) {
+ try {
+ return await fetchTTSBufferWithRetry(
+ openai,
+ { ...createParams, language: request.language },
+ signal,
+ upstreamSettings.ttsUpstreamMaxRetries,
+ );
+ } catch (error) {
+ const status = getUpstreamStatus(error);
+ if (status !== 400 && status !== 422) throw error;
+ const fallback = await fetchTTSBufferWithRetry(
+ openai,
+ createParams,
+ signal,
+ upstreamSettings.ttsUpstreamMaxRetries,
+ );
+ openAiCompatibleLanguageUnsupported.set(supportKey, true);
+ return fallback;
+ }
+ }
+ }
+
return fetchTTSBufferWithRetry(openai, createParams, signal, upstreamSettings.ttsUpstreamMaxRetries);
}
@@ -614,6 +688,7 @@ export async function generateTTSBuffer(
format: resolved.format,
text: resolved.text,
instructions: resolved.instructions,
+ language: resolved.language,
testNamespace: resolved.testNamespace,
});
diff --git a/src/lib/server/tts/segments.ts b/src/lib/server/tts/segments.ts
index d1bb3c7..e3618ae 100644
--- a/src/lib/server/tts/segments.ts
+++ b/src/lib/server/tts/segments.ts
@@ -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);
diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts
index d42ab3e..7d80a13 100644
--- a/src/lib/server/tts/voice-resolution.ts
+++ b/src/lib/server/tts/voice-resolution.ts
@@ -102,6 +102,19 @@ function walkRecordGraph(root: unknown, visit: (node: Record) =
}
const REPLICATE_VOICE_KEYS = ['voice', 'voice_id', 'speaker'] as const satisfies readonly ReplicateVoiceInputKey[];
+const REPLICATE_LANGUAGE_KEYS = [
+ 'language',
+ 'lang',
+ 'language_code',
+ 'locale',
+ 'language_id',
+ 'language_boost',
+] as const;
+export type ReplicateLanguageInputKey = typeof REPLICATE_LANGUAGE_KEYS[number];
+export type ReplicateLanguageInput = {
+ key: ReplicateLanguageInputKey;
+ allowedValues: string[];
+};
const REPLICATE_BUILT_IN_MODELS = new Set(
resolveProviderModels('replicate')
.map((model) => model.id)
@@ -150,6 +163,26 @@ function extractReplicateVoiceInputKeyFromOpenApiSchema(openApiSchema: unknown):
return found;
}
+function extractReplicateLanguageInputFromOpenApiSchema(openApiSchema: unknown): ReplicateLanguageInput | null {
+ let found: ReplicateLanguageInput | null = null;
+
+ walkRecordGraph(openApiSchema, (node) => {
+ const properties = node.properties;
+ if (!isRecord(properties)) return;
+ for (const key of REPLICATE_LANGUAGE_KEYS) {
+ if (key in properties) {
+ found = {
+ key,
+ allowedValues: extractSchemaStringEnums(properties[key]),
+ };
+ return true;
+ }
+ }
+ });
+
+ return found;
+}
+
async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promise {
const parsedModel = parseReplicateModelIdentifier(model);
if (!parsedModel) {
@@ -207,11 +240,15 @@ async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promi
}
const REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES = 128;
+const REPLICATE_LANGUAGE_INPUT_KEY_CACHE_MAX_ENTRIES = 128;
const REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES = 128;
const replicateVoiceInputKeyCache = new LRUCache({
max: REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES,
});
+const replicateLanguageInputCache = new LRUCache({
+ max: REPLICATE_LANGUAGE_INPUT_KEY_CACHE_MAX_ENTRIES,
+});
const replicateOpenApiSchemaPromiseCache = new LRUCache>({
max: REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES,
});
@@ -260,6 +297,33 @@ export async function resolveReplicateVoiceInputKey({
return inputKey;
}
+export async function resolveReplicateLanguageInputKey({
+ provider,
+ model,
+ apiKey = '',
+}: ResolveVoicesOptions): Promise {
+ const input = await resolveReplicateLanguageInput({ provider, model, apiKey });
+ return input?.key ?? null;
+}
+
+export async function resolveReplicateLanguageInput({
+ provider,
+ model,
+ apiKey = '',
+}: ResolveVoicesOptions): Promise {
+ if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) return null;
+
+ const cached = replicateLanguageInputCache.get(model);
+ if (cached) return cached;
+
+ const openApiSchema = await getReplicateOpenApiSchemaCached(apiKey, model);
+ const input = extractReplicateLanguageInputFromOpenApiSchema(openApiSchema);
+ if (input) {
+ replicateLanguageInputCache.set(model, input);
+ }
+ return input;
+}
+
async function fetchDeepinfraVoices(apiKey: string): Promise {
const controller = new AbortController();
const timeoutId = setTimeout(() => {
diff --git a/src/lib/shared/document-settings.ts b/src/lib/shared/document-settings.ts
index 26b45e0..208f76c 100644
--- a/src/lib/shared/document-settings.ts
+++ b/src/lib/shared/document-settings.ts
@@ -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;
+ 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;
return {
schemaVersion: 1,
+ language,
pdf: {
skipBlockKinds: normalizeSkipKinds(pdfRec.skipBlockKinds),
},
diff --git a/src/lib/shared/language.ts b/src/lib/shared/language.ts
new file mode 100644
index 0000000..b278bd0
--- /dev/null
+++ b/src/lib/shared/language.ts
@@ -0,0 +1,199 @@
+import { isKokoroModel } from '@/lib/shared/kokoro';
+
+export const DEFAULT_TTS_LANGUAGE = 'en';
+
+export interface TextToken {
+ text: string;
+ start: number;
+ end: number;
+}
+
+const KOKORO_LANGUAGE_BY_PREFIX: Readonly> = {
+ 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 const KOKORO_SUPPORTED_LANGUAGES = [
+ 'en',
+ 'es',
+ 'fr',
+ 'hi',
+ 'it',
+ 'ja',
+ 'pt-BR',
+ 'zh-CN',
+] as const;
+
+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 normalizeOptionalLanguageTag(language: unknown): string | null {
+ if (typeof language !== 'string') return null;
+ const candidate = language.trim().split(/[,\s]+/u)[0];
+ if (!candidate) return null;
+ try {
+ return Intl.getCanonicalLocales(candidate)[0] ?? null;
+ } catch {
+ return null;
+ }
+}
+
+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 {
+ const languages = new Set(getKokoroVoiceLanguages(voice));
+
+ return languages.size === 1 ? [...languages][0] : null;
+}
+
+export function getKokoroVoiceLanguages(voice: string | null | undefined): string[] {
+ if (!voice?.trim()) return [];
+ return Array.from(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)),
+ ));
+}
+
+export function keepKokoroVoicesInOneLanguage(
+ voices: string[],
+ preferredVoice?: string | null,
+): string[] {
+ const preferredLanguage = getKokoroVoiceLanguages(preferredVoice)[0]
+ ?? voices.flatMap((voice) => getKokoroVoiceLanguages(voice))[0];
+ if (!preferredLanguage) return voices;
+
+ const preferredBaseLanguage = toBaseLanguageCode(preferredLanguage);
+ return voices.filter((voice) => {
+ const voiceLanguage = getKokoroVoiceLanguages(voice)[0];
+ return !voiceLanguage || toBaseLanguageCode(voiceLanguage) === preferredBaseLanguage;
+ });
+}
+
+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 getLanguageDisplayName(language: string): string {
+ try {
+ return new Intl.DisplayNames(['en'], { type: 'language' }).of(language) || language;
+ } catch {
+ return language;
+ }
+}
+
+export function getTtsLanguageCompatibilityWarnings(input: {
+ model?: string | null;
+ voice?: string | null;
+ documentLanguage?: string | null;
+}): string[] {
+ if (!isKokoroModel(input.model || '')) return [];
+
+ const documentLanguage = normalizeLanguageTag(input.documentLanguage);
+ const documentBaseLanguage = toBaseLanguageCode(documentLanguage);
+ const supportedBaseLanguages = new Set(KOKORO_SUPPORTED_LANGUAGES.map((language) => toBaseLanguageCode(language)));
+ const voiceLanguages = getKokoroVoiceLanguages(input.voice);
+ const voiceBaseLanguages = new Set(voiceLanguages.map((language) => toBaseLanguageCode(language)));
+ const warnings: string[] = [];
+
+ if (!supportedBaseLanguages.has(documentBaseLanguage)) {
+ warnings.push(
+ `Kokoro's built-in voice catalog does not include ${getLanguageDisplayName(documentLanguage)}.`,
+ );
+ }
+
+ const voiceLanguage = voiceBaseLanguages.size === 1 ? voiceLanguages[0] : undefined;
+ if (voiceLanguage && toBaseLanguageCode(voiceLanguage) !== documentBaseLanguage) {
+ warnings.push(
+ `Selected Kokoro voice is ${getLanguageDisplayName(voiceLanguage)}, but the document is ${getLanguageDisplayName(documentLanguage)}.`,
+ );
+ }
+
+ return warnings;
+}
+
+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;
+ }
+}
diff --git a/src/lib/shared/nlp.ts b/src/lib/shared/nlp.ts
index b254102..6712116 100644
--- a/src/lib/shared/nlp.ts
+++ b/src/lib/shared/nlp.ts
@@ -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, WizardLM’s).
if (!(isPrevAlphaNum && isNextAlphaNum)) {
diff --git a/src/lib/shared/tts-segment-plan.ts b/src/lib/shared/tts-segment-plan.ts
index b33ca15..6f76e31 100644
--- a/src/lib/shared/tts-segment-plan.ts
+++ b/src/lib/shared/tts-segment-plan.ts
@@ -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)
diff --git a/src/types/client.ts b/src/types/client.ts
index 0b94363..f9c7440 100644
--- a/src/types/client.ts
+++ b/src/types/client.ts
@@ -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';
diff --git a/src/types/document-settings.ts b/src/types/document-settings.ts
index 6908f33..1d6b358 100644
--- a/src/types/document-settings.ts
+++ b/src/types/document-settings.ts
@@ -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'],
},
diff --git a/tests/files/multilingual-sample.txt b/tests/files/multilingual-sample.txt
new file mode 100644
index 0000000..5f9436c
--- /dev/null
+++ b/tests/files/multilingual-sample.txt
@@ -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 deuxième phrase.
+
+Spanish
+OpenReader debe segmentar correctamente esta frase. Esta es la segunda frase.
+
+Arabic
+يجب أن يقسم أوبن ريدر هذه الجملة بشكل صحيح. هذه هي الجملة الثانية.
+
+Hindi
+ओपनरीडर को इस वाक्य को सही ढंग से विभाजित करना चाहिए। यह दूसरा वाक्य है।
+
+Japanese
+OpenReaderはこの文を正しく分割する必要があります。これは二番目の文です。
+
+Chinese
+OpenReader应该正确地分割这个句子。这是第二句话。
+
+Thai
+OpenReader ควรแบ่งประโยคนี้อย่างถูกต้อง นี่คือประโยคที่สอง
diff --git a/tests/unit/audiobook-settings.vitest.spec.ts b/tests/unit/audiobook-settings.vitest.spec.ts
index 1cc371c..4417d01 100644
--- a/tests/unit/audiobook-settings.vitest.spec.ts
+++ b/tests/unit/audiobook-settings.vitest.spec.ts
@@ -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',
});
});
@@ -93,6 +95,7 @@ describe('coerceAudiobookGenerationSettings', () => {
postSpeed: 1,
format: 'mp3',
ttsInstructions: '',
+ language: 'en',
},
restrictUserApiKeys: true,
fallbackProviderRef: 'shared-openai',
@@ -111,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');
});
});
diff --git a/tests/unit/document-settings.vitest.spec.ts b/tests/unit/document-settings.vitest.spec.ts
new file mode 100644
index 0000000..9a2256c
--- /dev/null
+++ b/tests/unit/document-settings.vitest.spec.ts
@@ -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,
+ });
+ });
+});
diff --git a/tests/unit/epub-word-highlight.vitest.spec.ts b/tests/unit/epub-word-highlight.vitest.spec.ts
index 8899dc8..6d0e3cc 100644
--- a/tests/unit/epub-word-highlight.vitest.spec.ts
+++ b/tests/unit/epub-word-highlight.vitest.spec.ts
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
- buildMonotonicWordToTokenMap,
+ resolveAlignmentWordSourceRange,
tokenizeCanonicalSegment,
} from '../../src/lib/client/epub/epub-word-highlight';
import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan';
@@ -18,16 +18,46 @@ const segment = (text: string, offset = 0): CanonicalTtsSegment => ({
spansSourceBoundary: false,
});
-const alignmentWords = (words: string[]): TTSSentenceAlignment['words'] =>
- words.map((word, index) => ({
- text: word,
- startSec: index,
- endSec: index + 0.5,
- charStart: 0,
- charEnd: word.length,
- }));
-
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('resolves Japanese alignment chunks directly from character offsets', () => {
+ const japanese = segment('これは日本語です。', 25);
+ const word: TTSSentenceAlignment['words'][number] = {
+ text: 'これは',
+ startSec: 0,
+ endSec: 0.5,
+ charStart: 0,
+ charEnd: 3,
+ };
+
+ expect(resolveAlignmentWordSourceRange(japanese, word)).toEqual({
+ sourceStart: 25,
+ sourceEnd: 28,
+ });
+ });
+
+ test('rejects invalid alignment character offsets so token mapping can be used', () => {
+ const japanese = segment('これは日本語です。', 25);
+ const word: TTSSentenceAlignment['words'][number] = {
+ text: '範囲外',
+ startSec: 0,
+ endSec: 0.5,
+ charStart: 20,
+ charEnd: 23,
+ };
+
+ expect(resolveAlignmentWordSourceRange(japanese, word)).toBeNull();
+ });
+
test('tokenizes canonical segment words with source offsets', () => {
const tokens = tokenizeCanonicalSegment(segment('"Hello," she said.', 12));
@@ -38,23 +68,4 @@ describe('EPUB word highlight mapping', () => {
]);
});
- test('maps repeated words monotonically instead of jumping to later duplicates', () => {
- const tokens = tokenizeCanonicalSegment(segment('the light and the shadow and the light returned'));
- const map = buildMonotonicWordToTokenMap(
- alignmentWords(['the', 'light', 'and', 'the', 'shadow', 'and', 'the', 'light', 'returned']),
- tokens,
- );
-
- expect(map).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
- });
-
- test('leaves unmatched alignment words unhighlighted instead of borrowing a neighbor', () => {
- const tokens = tokenizeCanonicalSegment(segment('alpha beta gamma'));
- const map = buildMonotonicWordToTokenMap(
- alignmentWords(['alpha', 'missing', 'gamma']),
- tokens,
- );
-
- expect(map).toEqual([0, -1, 2]);
- });
});
diff --git a/tests/unit/highlight-token-alignment.vitest.spec.ts b/tests/unit/highlight-token-alignment.vitest.spec.ts
new file mode 100644
index 0000000..32fc7d5
--- /dev/null
+++ b/tests/unit/highlight-token-alignment.vitest.spec.ts
@@ -0,0 +1,107 @@
+import { describe, expect, test } from 'vitest';
+
+import {
+ buildAlignmentTokenRanges,
+ findBestHighlightTokenMatch,
+} from '../../src/lib/client/highlight-token-alignment';
+import { segmentWords } from '../../src/lib/shared/language';
+import type { TTSSentenceAlignment } from '../../src/types/tts';
+
+const alignmentWords = (
+ words: string[],
+): TTSSentenceAlignment['words'] =>
+ words.map((word, index) => ({
+ text: word,
+ startSec: index,
+ endSec: index + 0.5,
+ charStart: 0,
+ charEnd: word.length,
+ }));
+
+describe('shared viewer highlight token alignment', () => {
+ test('finds an exact sentence in a large document without entering fuzzy comparison', () => {
+ const targets = Array.from({ length: 20_000 }, (_, index) => `token-${index}`);
+ targets.splice(15_000, 4, 'the', 'exact', 'sentence', 'here');
+
+ expect(findBestHighlightTokenMatch(
+ ['the', 'exact', 'sentence', 'here'],
+ targets,
+ )).toEqual({
+ start: 15_000,
+ end: 15_003,
+ rating: 1,
+ lengthDiff: 0,
+ });
+ });
+
+ test('matches a complete Japanese sentence using locale-aware token count', () => {
+ const sentence = 'これは日本語です。';
+ const patternTokens = segmentWords(sentence, 'ja').map((token) => token.text);
+ const tokenTexts = ['前文', ...patternTokens, '次文'];
+
+ expect(findBestHighlightTokenMatch(patternTokens, tokenTexts)).toMatchObject({
+ start: 1,
+ end: patternTokens.length,
+ lengthDiff: 0,
+ });
+ });
+
+ test('matches spaced Latin text without relying on whitespace tokens', () => {
+ expect(findBestHighlightTokenMatch(
+ ['hello', 'world'],
+ ['before', 'hello', 'world', 'after'],
+ )).toMatchObject({
+ start: 1,
+ end: 2,
+ lengthDiff: 0,
+ });
+ });
+
+ test('maps a Japanese timed chunk across every visible token it spans', () => {
+ expect(buildAlignmentTokenRanges(
+ alignmentWords(['これは', '日本語', 'です']),
+ ['これ', 'は', '日本語', 'です'],
+ )).toEqual([
+ { start: 0, end: 1 },
+ { start: 2, end: 2 },
+ { start: 3, end: 3 },
+ ]);
+ });
+
+ test('maps visible chunks back to a larger timed Japanese token', () => {
+ expect(buildAlignmentTokenRanges(
+ alignmentWords(['これ', 'は', '日本語', 'です']),
+ ['これは', '日本語', 'です'],
+ )).toEqual([
+ { start: 0, end: 0 },
+ { start: 0, end: 0 },
+ { start: 1, end: 1 },
+ { start: 2, end: 2 },
+ ]);
+ });
+
+ test('maps repeated words monotonically', () => {
+ expect(buildAlignmentTokenRanges(
+ alignmentWords(['the', 'light', 'and', 'the', 'light']),
+ ['the', 'light', 'and', 'the', 'light'],
+ )).toEqual([
+ { start: 0, end: 0 },
+ { start: 1, end: 1 },
+ { start: 2, end: 2 },
+ { start: 3, end: 3 },
+ { start: 4, end: 4 },
+ ]);
+ });
+
+ test('can leave unrelated fallback tokens unmapped for strict viewers', () => {
+ expect(buildAlignmentTokenRanges(
+ alignmentWords(['alpha', 'missing', 'gamma']),
+ ['alpha', 'beta', 'gamma'],
+ { minimumSimilarity: 0.8 },
+ )).toEqual([
+ { start: 0, end: 0 },
+ null,
+ { start: 2, end: 2 },
+ ]);
+ });
+});
diff --git a/tests/unit/language.vitest.spec.ts b/tests/unit/language.vitest.spec.ts
new file mode 100644
index 0000000..b50552c
--- /dev/null
+++ b/tests/unit/language.vitest.spec.ts
@@ -0,0 +1,124 @@
+import { describe, expect, test } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+
+import {
+ getTtsLanguageCompatibilityWarnings,
+ inferKokoroLanguageFromVoice,
+ keepKokoroVoicesInOneLanguage,
+ normalizeOptionalLanguageTag,
+ 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('keeps Kokoro multi-voice selections within the newly selected language', () => {
+ expect(keepKokoroVoicesInOneLanguage(
+ ['af_sarah', 'bf_emma', 'jf_alpha'],
+ 'jf_alpha',
+ )).toEqual(['jf_alpha']);
+
+ expect(keepKokoroVoicesInOneLanguage(
+ ['zf_xiaobei', 'zm_yunxi', 'af_sarah'],
+ 'zm_yunxi',
+ )).toEqual(['zf_xiaobei', 'zm_yunxi']);
+
+ expect(keepKokoroVoicesInOneLanguage(
+ ['af_sarah', 'bf_emma'],
+ 'bf_emma',
+ )).toEqual(['af_sarah', 'bf_emma']);
+ });
+
+ test('normalizes valid EPUB metadata language tags and rejects invalid metadata', () => {
+ expect(normalizeOptionalLanguageTag(' ja-jp ')).toBe('ja-JP');
+ expect(normalizeOptionalLanguageTag('fr, en')).toBe('fr');
+ expect(normalizeOptionalLanguageTag('not_a_language')).toBeNull();
+ expect(normalizeOptionalLanguageTag(undefined)).toBeNull();
+ });
+
+ test('warns only for provable Kokoro language compatibility problems', () => {
+ expect(getTtsLanguageCompatibilityWarnings({
+ model: 'kokoro',
+ voice: 'jf_alpha',
+ documentLanguage: 'ja',
+ })).toEqual([]);
+
+ expect(getTtsLanguageCompatibilityWarnings({
+ model: 'kokoro',
+ voice: 'af_sarah',
+ documentLanguage: 'ja',
+ })).toEqual([
+ 'Selected Kokoro voice is American English, but the document is Japanese.',
+ ]);
+
+ expect(getTtsLanguageCompatibilityWarnings({
+ model: 'kokoro',
+ voice: 'ff_siwis+jf_alpha',
+ documentLanguage: 'fr',
+ })).toEqual([]);
+
+ expect(getTtsLanguageCompatibilityWarnings({
+ model: 'kokoro',
+ voice: 'af_sarah',
+ documentLanguage: 'ar',
+ })).toEqual([
+ "Kokoro's built-in voice catalog does not include Arabic.",
+ 'Selected Kokoro voice is American English, but the document is Arabic.',
+ ]);
+
+ expect(getTtsLanguageCompatibilityWarnings({
+ model: 'custom-unknown-model',
+ voice: 'af_sarah',
+ documentLanguage: 'ja',
+ })).toEqual([]);
+ });
+
+ 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);
+ }
+ });
+});
diff --git a/tests/unit/nlp.vitest.spec.ts b/tests/unit/nlp.vitest.spec.ts
index 4753b20..b259690 100644
--- a/tests/unit/nlp.vitest.spec.ts
+++ b/tests/unit/nlp.vitest.spec.ts
@@ -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('最初の文です。次の文です。');
+ });
});
diff --git a/tests/unit/shared-form-control-consumers.vitest.spec.ts b/tests/unit/shared-form-control-consumers.vitest.spec.ts
new file mode 100644
index 0000000..10d0004
--- /dev/null
+++ b/tests/unit/shared-form-control-consumers.vitest.spec.ts
@@ -0,0 +1,43 @@
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { describe, expect, test } from 'vitest';
+
+const STANDARD_FORM_CONSUMERS = [
+ 'src/app/(app)/signin/page.tsx',
+ 'src/app/(app)/signup/page.tsx',
+ 'src/components/PrivacyModal.tsx',
+ 'src/components/SettingsModal.tsx',
+ 'src/components/admin/AdminFeaturesPanel.tsx',
+ 'src/components/admin/AdminProvidersPanel.tsx',
+ 'src/components/documents/DocumentSelectionModal.tsx',
+];
+
+describe('shared form-control consumers', () => {
+ test('standard forms use shared inputs, textareas, and checkboxes', () => {
+ for (const relativePath of STANDARD_FORM_CONSUMERS) {
+ const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
+ expect(source, relativePath).not.toMatch(/<(input|textarea)\b/);
+ expect(source, relativePath).not.toContain('inputClass');
+ }
+ });
+
+ test('auth pages use the shared inline button', () => {
+ for (const relativePath of [
+ 'src/app/(app)/signin/page.tsx',
+ 'src/app/(app)/signup/page.tsx',
+ ]) {
+ const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
+ expect(source, relativePath).toContain(' {
+ const inputSource = readFileSync(resolve(process.cwd(), 'src/components/ui/input.tsx'), 'utf8');
+ const checkboxSource = readFileSync(resolve(process.cwd(), 'src/components/ui/checkbox.tsx'), 'utf8');
+ expect(inputSource).toContain('inputStyles');
+ expect(inputSource).toContain('export function Textarea');
+ expect(checkboxSource).toContain('checkboxClass');
+ expect(checkboxSource).toContain('export const Checkbox');
+ });
+});
diff --git a/tests/unit/shared-overlay-consumers.vitest.spec.ts b/tests/unit/shared-overlay-consumers.vitest.spec.ts
new file mode 100644
index 0000000..035dc72
--- /dev/null
+++ b/tests/unit/shared-overlay-consumers.vitest.spec.ts
@@ -0,0 +1,31 @@
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { describe, expect, test } from 'vitest';
+
+const STANDARD_OVERLAY_CONSUMERS = [
+ 'src/components/admin/AdminProvidersPanel.tsx',
+ 'src/components/AudiobookExportModal.tsx',
+ 'src/components/ColorPicker.tsx',
+ 'src/components/doclist/window/FinderSidebar.tsx',
+ 'src/components/documents/DocumentHeaderMenu.tsx',
+ 'src/components/player/Navigator.tsx',
+ 'src/components/player/SpeedControl.tsx',
+];
+
+describe('shared overlay consumers', () => {
+ test('standard menus and popovers do not compose Headless UI directly', () => {
+ for (const relativePath of STANDARD_OVERLAY_CONSUMERS) {
+ const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
+ expect(source, relativePath).not.toContain("from '@headlessui/react'");
+ }
+ });
+
+ test('shared overlay primitives own standard composition', () => {
+ const menuSource = readFileSync(resolve(process.cwd(), 'src/components/ui/menu.tsx'), 'utf8');
+ const popoverSource = readFileSync(resolve(process.cwd(), 'src/components/ui/popover.tsx'), 'utf8');
+ expect(menuSource).toContain('export const MenuRoot');
+ expect(menuSource).toContain('export function MenuTransition');
+ expect(popoverSource).toContain('export const PopoverRoot');
+ expect(popoverSource).toContain('export function PopoverIconTrigger');
+ });
+});
diff --git a/tests/unit/shared-select-consumers.vitest.spec.ts b/tests/unit/shared-select-consumers.vitest.spec.ts
new file mode 100644
index 0000000..2634704
--- /dev/null
+++ b/tests/unit/shared-select-consumers.vitest.spec.ts
@@ -0,0 +1,31 @@
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+import { describe, expect, test } from 'vitest';
+
+const STANDARD_SELECT_CONSUMERS = [
+ 'src/components/SettingsModal.tsx',
+ 'src/components/documents/DocumentSettings.tsx',
+ 'src/components/admin/AdminFeaturesPanel.tsx',
+ 'src/components/admin/AdminProvidersPanel.tsx',
+ 'src/components/AudiobookExportModal.tsx',
+];
+
+describe('shared Select consumers', () => {
+ test('standard settings dropdowns use the high-level UI primitive', () => {
+ for (const relativePath of STANDARD_SELECT_CONSUMERS) {
+ const source = readFileSync(resolve(process.cwd(), relativePath), 'utf8');
+ expect(source, relativePath).toContain(' {
+ const source = readFileSync(resolve(process.cwd(), 'src/components/ui/select.tsx'), 'utf8');
+ expect(source).toContain('ChevronUpDownIcon');
+ expect(source).toContain('CheckIcon');
+ expect(source).toContain(' {
test('returns direct URL string output', () => {
@@ -41,3 +45,29 @@ describe('replicate output URL extraction', () => {
expect(extractReplicateAudioUrl(output)).toBeNull();
});
});
+
+describe('TTS upstream cache identity', () => {
+ test('includes language in the upstream audio cache key', () => {
+ const request = {
+ text: '同じ文章です。',
+ voice: 'jf_alpha',
+ speed: 1,
+ format: 'mp3',
+ model: 'kokoro',
+ provider: 'custom-openai',
+ apiKey: 'test',
+ };
+
+ expect(buildTTSCacheKey({ ...request, language: 'ja' }))
+ .not.toBe(buildTTSCacheKey({ ...request, language: 'en' }));
+ });
+});
+
+describe('Replicate language schema values', () => {
+ test('uses language codes or advertised display names without a model table', () => {
+ expect(resolveReplicateLanguageValue('ja-JP', [])).toBe('ja-JP');
+ expect(resolveReplicateLanguageValue('ja-JP', ['en', 'ja', 'zh'])).toBe('ja');
+ expect(resolveReplicateLanguageValue('ja-JP', ['English', 'Japanese'])).toBe('Japanese');
+ expect(resolveReplicateLanguageValue('ja-JP', ['English', 'French'])).toBeNull();
+ });
+});
diff --git a/tests/unit/tts-provider-catalog.vitest.spec.ts b/tests/unit/tts-provider-catalog.vitest.spec.ts
index 334ea16..8996617 100644
--- a/tests/unit/tts-provider-catalog.vitest.spec.ts
+++ b/tests/unit/tts-provider-catalog.vitest.spec.ts
@@ -9,7 +9,11 @@ import {
supportsTtsInstructions,
} from '../../src/lib/shared/tts-provider-catalog';
import { normalizeLegacyProviderRef, resolveProviderDefaults, resolveTtsModelForProvider } from '../../src/lib/shared/tts-provider-policy';
-import { resolveReplicateVoiceInputKey, resolveVoices } from '../../src/lib/server/tts/voice-resolution';
+import {
+ resolveReplicateLanguageInputKey,
+ resolveReplicateVoiceInputKey,
+ resolveVoices,
+} from '../../src/lib/server/tts/voice-resolution';
import { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
@@ -387,6 +391,50 @@ describe('tts provider catalog', () => {
globalThis.fetch = originalFetch;
}
});
+
+ test('discovers Replicate language input key from model schema', async () => {
+ const originalFetch = globalThis.fetch;
+ let calls = 0;
+ globalThis.fetch = async () => {
+ calls += 1;
+ return {
+ ok: true,
+ json: async () => ({
+ latest_version: {
+ openapi_schema: {
+ components: {
+ schemas: {
+ Input: {
+ type: 'object',
+ properties: {
+ text: { type: 'string' },
+ language_code: { type: 'string' },
+ },
+ },
+ },
+ },
+ },
+ },
+ }),
+ } as Response;
+ };
+
+ try {
+ await expect(resolveReplicateLanguageInputKey({
+ provider: 'replicate',
+ model: 'acme/schema-language-model',
+ apiKey: 'r8_token',
+ })).resolves.toBe('language_code');
+ await expect(resolveReplicateLanguageInputKey({
+ provider: 'replicate',
+ model: 'acme/schema-language-model',
+ apiKey: 'r8_token',
+ })).resolves.toBe('language_code');
+ expect(calls).toBe(1);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
});
describe('config helpers', () => {
diff --git a/tests/unit/tts-segments.vitest.spec.ts b/tests/unit/tts-segments.vitest.spec.ts
index def8e9f..a448afe 100644
--- a/tests/unit/tts-segments.vitest.spec.ts
+++ b/tests/unit/tts-segments.vitest.spec.ts
@@ -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);
+ }
+ });
});