From 07e27430c2e1547e7d20f7b5156a3791240691c0 Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 06:18:50 -0600
Subject: [PATCH 01/12] feat(tts): add multilingual reader foundation
---
src/app/(app)/epub/[id]/page.tsx | 12 +-
src/app/(app)/epub/[id]/useEpubDocument.ts | 12 +-
src/app/(app)/html/[id]/page.tsx | 13 +-
src/app/(app)/pdf/[id]/page.tsx | 8 ++
src/app/(app)/pdf/[id]/usePdfDocument.ts | 6 +
src/app/api/audiobook/chapter/route.ts | 3 +-
src/app/api/tts/segments/ensure/route.ts | 6 +
src/app/api/tts/segments/manifest/route.ts | 3 +-
src/components/AudiobookExportModal.tsx | 9 +-
src/components/documents/DocumentSettings.tsx | 42 ++++++-
src/components/views/HTMLViewer.tsx | 4 +-
src/components/views/PDFViewer.tsx | 3 +
src/contexts/TTSContext.tsx | 39 +++++-
src/hooks/epub/useEPUBHighlighting.ts | 7 +-
src/hooks/useDocumentLanguage.ts | 52 ++++++++
src/lib/client/audiobooks/pipeline.ts | 8 +-
src/lib/client/epub/epub-word-highlight.ts | 43 +++----
src/lib/client/html/highlight.ts | 52 ++++----
src/lib/client/pdf.ts | 19 ++-
src/lib/server/audiobooks/settings.ts | 2 +
src/lib/server/tts/segments.ts | 38 +++---
src/lib/shared/document-settings.ts | 9 +-
src/lib/shared/language.ts | 119 ++++++++++++++++++
src/lib/shared/nlp.ts | 48 +++----
src/lib/shared/tts-segment-plan.ts | 8 +-
src/types/client.ts | 2 +
src/types/document-settings.ts | 2 +
tests/files/multilingual-sample.txt | 23 ++++
tests/unit/audiobook-settings.vitest.spec.ts | 2 +
tests/unit/document-settings.vitest.spec.ts | 22 ++++
tests/unit/epub-word-highlight.vitest.spec.ts | 10 ++
tests/unit/language.vitest.spec.ts | 60 +++++++++
tests/unit/nlp.vitest.spec.ts | 21 ++++
tests/unit/tts-segments.vitest.spec.ts | 15 +++
34 files changed, 590 insertions(+), 132 deletions(-)
create mode 100644 src/hooks/useDocumentLanguage.ts
create mode 100644 src/lib/shared/language.ts
create mode 100644 tests/files/multilingual-sample.txt
create mode 100644 tests/unit/document-settings.vitest.spec.ts
create mode 100644 tests/unit/language.vitest.spec.ts
diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx
index b08d4d4..3cc6ebd 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';
@@ -37,7 +38,8 @@ export default function EPUBPage() {
regenerateChapter: regenerateEPUBChapter,
bookRef,
} = epubState;
- const { stop } = useTTS();
+ const { stop, setDocumentLanguage } = useTTS();
+ const { language, updateLanguage } = useDocumentLanguage(routeDocumentId);
const { isAtLimit } = useAuthRateLimit();
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -106,6 +108,10 @@ export default function EPUBPage() {
useUnmountCleanupRef(clearCurrDoc);
+ useEffect(() => {
+ setDocumentLanguage(language);
+ }, [language, setDocumentLanguage]);
+
// Compute available height = viewport - (header height + tts bar height)
useEffect(() => {
const compute = () => {
@@ -249,6 +255,10 @@ export default function EPUBPage() {
epub
isOpen={activeSidebar === 'settings'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
+ language={language}
+ onLanguageChange={(nextLanguage) => {
+ void updateLanguage(nextLanguage);
+ }}
/>
() => {
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/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts
index 70e78f7..31f40e6 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 });
}
diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts
index fdd6b2d..e892784 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;
@@ -674,6 +679,7 @@ export async function POST(request: NextRequest) {
alignment = await userWhisperAlignJob({
audioObjectKey: audioKey,
text: segment.text,
+ lang: effectiveSettings.language,
sentenceIndex: segment.original.segmentIndex,
});
stageTimings.whisperAlignMs = Date.now() - alignStartedAt;
diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts
index c78e602..686555c 100644
--- a/src/app/api/tts/segments/manifest/route.ts
+++ b/src/app/api/tts/segments/manifest/route.ts
@@ -71,9 +71,10 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
const instructionsSource = rec.ttsInstructions ?? rec.instructions;
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : '';
+ const language = typeof rec.language === 'string' ? rec.language : 'en';
if (!providerRef || !providerType || !ttsModel || !voice) return null;
- return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions };
+ return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions, language };
}
function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {
diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx
index 0bd98b5..9595c46 100644
--- a/src/components/AudiobookExportModal.tsx
+++ b/src/components/AudiobookExportModal.tsx
@@ -12,6 +12,7 @@ import { useTTS } from '@/contexts/TTSContext';
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
+import { resolveTtsLanguage } from '@/lib/shared/language';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui';
import {
@@ -50,7 +51,7 @@ export function AudiobookExportModal({
onRegenerateChapter
}: AudiobookExportModalProps) {
const { isLoading, isDBReady, providerRef, providerType, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig();
- const { availableVoices } = useTTS();
+ const { availableVoices, documentLanguage } = useTTS();
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const [isGenerating, setIsGenerating] = useState(false);
const [chapters, setChapters] = useState([]);
@@ -125,8 +126,12 @@ export function AudiobookExportModal({
postSpeed,
format,
ttsInstructions: providerModelPolicy.supportsInstructions ? ttsInstructions : undefined,
+ language: resolveTtsLanguage({
+ configuredLanguage: documentLanguage,
+ voice: nextVoice,
+ }),
};
- }, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions]);
+ }, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions, documentLanguage]);
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
if (soft) {
diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx
index 07442c2..0e1b0ca 100644
--- a/src/components/documents/DocumentSettings.tsx
+++ b/src/components/documents/DocumentSettings.tsx
@@ -50,6 +50,20 @@ const viewTypeTextMapping = [
{ id: 'scroll', name: 'Continuous Scroll' },
];
+const DOCUMENT_LANGUAGE_OPTIONS = [
+ { value: 'auto', label: 'Automatic (voice or metadata)' },
+ { value: 'en', label: 'English' },
+ { value: 'es', label: 'Spanish' },
+ { value: 'fr', label: 'French' },
+ { value: 'hi', label: 'Hindi' },
+ { value: 'it', label: 'Italian' },
+ { value: 'ja', label: 'Japanese' },
+ { value: 'pt-BR', label: 'Portuguese (Brazil)' },
+ { value: 'zh-CN', label: 'Chinese (Simplified)' },
+ { value: 'ar', label: 'Arabic' },
+ { value: 'th', label: 'Thai' },
+];
+
type RangeSettingProps = {
label: string;
value: number;
@@ -92,11 +106,13 @@ function RangeSetting({
);
}
-export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
+export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, onLanguageChange, pdf }: {
isOpen: boolean,
setIsOpen: (isOpen: boolean) => void,
epub?: boolean,
html?: boolean,
+ language?: string,
+ onLanguageChange?: (language: string) => void,
pdf?: {
parseStatus: PdfParseStatus | null;
parsedOverlayEnabled: boolean;
@@ -151,6 +167,30 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
panelClassName="w-full sm:w-[30rem]"
>
+ {language && onLanguageChange ? (
+
+
+
+ Document language
+
+ onLanguageChange(event.target.value)}
+ className="w-full rounded-md border border-offbase bg-surface-solid px-3 py-2 text-sm text-foreground"
+ >
+ {DOCUMENT_LANGUAGE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ ) : null}
{isPdfMode && pdf && (
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..8c31d34 100644
--- a/src/hooks/epub/useEPUBHighlighting.ts
+++ b/src/hooks/epub/useEPUBHighlighting.ts
@@ -30,6 +30,7 @@ type UseEpubHighlightingParams = {
currentWordHighlightCfiRef: MutableRefObject;
renderedTextMapsRef: MutableRefObject;
wordHighlightMapCacheRef: MutableRefObject;
+ language?: string;
};
type UseEpubHighlightingResult = {
@@ -52,6 +53,7 @@ export function useEPUBHighlighting({
currentWordHighlightCfiRef,
renderedTextMapsRef,
wordHighlightMapCacheRef,
+ language,
}: UseEpubHighlightingParams): UseEpubHighlightingResult {
const clearWordHighlights = useCallback(() => {
if (!renditionRef.current) return;
@@ -116,9 +118,9 @@ export function useEPUBHighlighting({
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return;
- const cacheKey = buildWordHighlightCacheKey(segment, alignment);
+ const cacheKey = buildWordHighlightCacheKey(segment, alignment, language);
if (wordHighlightMapCacheRef.current?.key !== cacheKey) {
- const tokens = tokenizeCanonicalSegment(segment);
+ const tokens = tokenizeCanonicalSegment(segment, language);
wordHighlightMapCacheRef.current = {
key: cacheKey,
tokens,
@@ -162,6 +164,7 @@ export function useEPUBHighlighting({
renderedTextMapsRef,
renditionRef,
wordHighlightMapCacheRef,
+ language,
]);
const setRenderedTextMaps = useCallback((maps: EpubRenderedTextMap[]) => {
diff --git a/src/hooks/useDocumentLanguage.ts b/src/hooks/useDocumentLanguage.ts
new file mode 100644
index 0000000..f8474ab
--- /dev/null
+++ b/src/hooks/useDocumentLanguage.ts
@@ -0,0 +1,52 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+
+import { getDocumentSettings, putDocumentSettings } from '@/lib/client/api/documents';
+import { mergeDocumentSettings } from '@/lib/shared/document-settings';
+import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
+
+export function useDocumentLanguage(documentId: string | undefined): {
+ language: string;
+ updateLanguage: (language: string) => Promise;
+} {
+ 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;
+ const next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
+ ...settings,
+ schemaVersion: 1,
+ language,
+ });
+ setSettings(next);
+ try {
+ const response = await putDocumentSettings(documentId, next);
+ setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
+ } catch (error) {
+ console.warn('Failed to persist document language:', error);
+ }
+ }, [documentId, settings]);
+
+ return {
+ language: settings.language ?? 'auto',
+ updateLanguage,
+ };
+}
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..5e90cda 100644
--- a/src/lib/client/epub/epub-word-highlight.ts
+++ b/src/lib/client/epub/epub-word-highlight.ts
@@ -1,5 +1,6 @@
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
import type { TTSSentenceAlignment } from '@/types/tts';
+import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
export type EpubCanonicalWordToken = {
norm: string;
@@ -8,35 +9,19 @@ export type EpubCanonicalWordToken = {
};
export const normalizeWordForHighlight = (text: string): string =>
- text
- .toLowerCase()
- .replace(/[^a-z0-9]+/g, '');
+ normalizeUnicodeToken(text);
-export const tokenizeCanonicalSegment = (segment: CanonicalTtsSegment): EpubCanonicalWordToken[] => {
- const tokens: EpubCanonicalWordToken[] = [];
- const wordRegex = /\S+/g;
- let match: RegExpExecArray | null;
-
- while ((match = wordRegex.exec(segment.text)) !== null) {
- const raw = match[0];
- const leading = raw.match(/^[^A-Za-z0-9]*/)?.[0].length ?? 0;
- const trailing = raw.match(/[^A-Za-z0-9]*$/)?.[0].length ?? 0;
- const start = match.index + leading;
- const end = match.index + raw.length - trailing;
- if (end <= start) continue;
-
- const norm = normalizeWordForHighlight(raw.slice(leading, raw.length - trailing));
- if (!norm) continue;
-
- tokens.push({
- norm,
- sourceStart: segment.startAnchor.offset + start,
- sourceEnd: segment.startAnchor.offset + end,
- });
- }
-
- return tokens;
-};
+export const tokenizeCanonicalSegment = (
+ segment: CanonicalTtsSegment,
+ language?: string,
+): EpubCanonicalWordToken[] =>
+ segmentWords(segment.text, language)
+ .map((token) => ({
+ norm: normalizeWordForHighlight(token.text),
+ sourceStart: segment.startAnchor.offset + token.start,
+ sourceEnd: segment.startAnchor.offset + token.end,
+ }))
+ .filter((token) => Boolean(token.norm));
export const buildMonotonicWordToTokenMap = (
alignmentWords: TTSSentenceAlignment['words'],
@@ -105,10 +90,12 @@ export const buildMonotonicWordToTokenMap = (
export const buildWordHighlightCacheKey = (
segment: CanonicalTtsSegment,
alignment: TTSSentenceAlignment,
+ language?: string,
): string =>
[
segment.key,
segment.text.length,
+ language || '',
alignment.words.length,
alignment.words.map((word) => normalizeWordForHighlight(word.text)).join('|'),
].join('::');
diff --git a/src/lib/client/html/highlight.ts b/src/lib/client/html/highlight.ts
index 104765d..db26cc2 100644
--- a/src/lib/client/html/highlight.ts
+++ b/src/lib/client/html/highlight.ts
@@ -18,6 +18,7 @@
*/
import { CmpStr } from 'cmpstr';
import type { TTSSentenceAlignment } from '@/types/tts';
+import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
export const HTML_SENTENCE_CLASS = 'openreader-html-highlight-sentence';
export const HTML_WORD_CLASS = 'openreader-html-highlight-word';
@@ -53,21 +54,11 @@ interface SentenceState {
let sentenceState: SentenceState | null = null;
function normalizeWord(word: string): string {
- return word
- .toLowerCase()
- .replace(/[\p{P}\p{S}]+/gu, '')
- .trim();
+ return normalizeUnicodeToken(word);
}
-function tokenizePattern(pattern: string): string[] {
- const out: string[] = [];
- const wordRe = /\S+/g;
- let m: RegExpExecArray | null;
- while ((m = wordRe.exec(pattern)) !== null) {
- const norm = normalizeWord(m[0]);
- if (norm) out.push(norm);
- }
- return out;
+function tokenizePattern(pattern: string, language?: string): string[] {
+ return segmentWords(pattern, language).map((token) => normalizeWord(token.text)).filter(Boolean);
}
function unwrap(span: HTMLSpanElement): void {
@@ -108,7 +99,11 @@ function isHighlightWrapper(node: Node | null): node is HTMLSpanElement {
return el.classList.contains(HTML_SENTENCE_CLASS) || el.classList.contains(HTML_WORD_CLASS);
}
-function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean } = { skipHighlightWraps: true }): DomToken[] {
+function collectDomTokens(
+ root: HTMLElement,
+ language?: string,
+ opts: { skipHighlightWraps: boolean } = { skipHighlightWraps: true },
+): DomToken[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
@@ -131,15 +126,13 @@ function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean
while (current) {
const textNode = current as Text;
const text = textNode.nodeValue || '';
- const wordRe = /\S+/g;
- let m: RegExpExecArray | null;
- while ((m = wordRe.exec(text)) !== null) {
- const norm = normalizeWord(m[0]);
+ for (const token of segmentWords(text, language)) {
+ const norm = normalizeWord(token.text);
if (!norm) continue;
tokens.push({
textNode,
- startOffset: m.index,
- endOffset: m.index + m[0].length,
+ startOffset: token.start,
+ endOffset: token.end,
norm,
});
}
@@ -153,7 +146,7 @@ function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean
* wrap is applied; lets us index just the words *within* the highlighted
* sentence rather than the whole document).
*/
-function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
+function collectTokensInsideWraps(wraps: HTMLSpanElement[], language?: string): DomToken[] {
const tokens: DomToken[] = [];
for (const wrap of wraps) {
const walker = document.createTreeWalker(wrap, NodeFilter.SHOW_TEXT);
@@ -161,15 +154,13 @@ function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
while (current) {
const t = current as Text;
const text = t.nodeValue || '';
- const wordRe = /\S+/g;
- let m: RegExpExecArray | null;
- while ((m = wordRe.exec(text)) !== null) {
- const norm = normalizeWord(m[0]);
+ for (const token of segmentWords(text, language)) {
+ const norm = normalizeWord(token.text);
if (!norm) continue;
tokens.push({
textNode: t,
- startOffset: m.index,
- endOffset: m.index + m[0].length,
+ startOffset: token.start,
+ endOffset: token.end,
norm,
});
}
@@ -260,14 +251,15 @@ function wrapTokenRange(tokens: DomToken[], start: number, end: number, classNam
export function highlightHtmlSentence(
container: HTMLElement | null | undefined,
sentence: string | null | undefined,
+ language?: string,
): boolean {
clearHtmlSentenceHighlight();
if (!container || !sentence?.trim()) return false;
- const patternTokens = tokenizePattern(sentence);
+ const patternTokens = tokenizePattern(sentence, language);
if (!patternTokens.length) return false;
- const domTokens = collectDomTokens(container);
+ const domTokens = collectDomTokens(container, language);
if (!domTokens.length) return false;
const win = findBestWindow(domTokens, patternTokens);
@@ -280,7 +272,7 @@ export function highlightHtmlSentence(
// can look up individual word tokens without re-walking the doc.
sentenceState = {
sentence,
- wordTokens: collectTokensInsideWraps(sentenceWraps),
+ wordTokens: collectTokensInsideWraps(sentenceWraps, language),
alignment: null,
wordToToken: null,
};
diff --git a/src/lib/client/pdf.ts b/src/lib/client/pdf.ts
index 9727d4b..32a1a7e 100644
--- a/src/lib/client/pdf.ts
+++ b/src/lib/client/pdf.ts
@@ -5,6 +5,7 @@ import type { TTSSentenceAlignment } from '@/types/tts';
import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf';
import { CmpStr } from 'cmpstr';
import type { TTSSegmentLocator } from '@/types/client';
+import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
@@ -181,10 +182,7 @@ function getOrCreateHighlightLayer(span: HTMLElement): {
}
const normalizeWordForMatch = (text: string): string =>
- text
- .trim()
- .replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '')
- .toLowerCase();
+ normalizeUnicodeToken(text);
// Highlighting functions
let highlightPatternSeq = 0;
@@ -193,6 +191,7 @@ type HighlightPatternOptions = {
parsedDocument?: ParsedPdfDocument | null;
locator?: TTSSegmentLocator | null;
useBlockGeometryOnly?: boolean;
+ language?: string;
};
function getHighlightLayerForPage(pageElement: HTMLElement): {
@@ -451,17 +450,13 @@ export function highlightPattern(
const textNode = node as Text;
const textContent = textNode.textContent || '';
- const wordRegex = /\S+/g;
- let match: RegExpExecArray | null;
-
- while ((match = wordRegex.exec(textContent)) !== null) {
- const word = match[0];
+ for (const token of segmentWords(textContent, options?.language)) {
tokens.push({
spanIndex,
textNode,
- text: word,
- startOffset: match.index,
- endOffset: match.index + word.length,
+ text: token.text,
+ startOffset: token.start,
+ endOffset: token.end,
});
}
});
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/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/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..b2e3437
--- /dev/null
+++ b/src/lib/shared/language.ts
@@ -0,0 +1,119 @@
+export const DEFAULT_TTS_LANGUAGE = 'en';
+
+export interface TextToken {
+ text: string;
+ start: number;
+ end: number;
+}
+
+const KOKORO_LANGUAGE_BY_PREFIX: Readonly> = {
+ af: 'en-US',
+ am: 'en-US',
+ bf: 'en-GB',
+ bm: 'en-GB',
+ ef: 'es',
+ em: 'es',
+ ff: 'fr',
+ hf: 'hi',
+ hm: 'hi',
+ if: 'it',
+ im: 'it',
+ jf: 'ja',
+ jm: 'ja',
+ pf: 'pt-BR',
+ pm: 'pt-BR',
+ zf: 'zh-CN',
+ zm: 'zh-CN',
+};
+
+export function normalizeLanguageTag(
+ language: string | null | undefined,
+ fallback = DEFAULT_TTS_LANGUAGE,
+): string {
+ const candidate = language?.trim();
+ if (!candidate || candidate.toLowerCase() === 'auto') return fallback;
+
+ try {
+ return Intl.getCanonicalLocales(candidate)[0] ?? fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+export function toBaseLanguageCode(language: string | null | undefined): string {
+ const normalized = normalizeLanguageTag(language);
+ try {
+ return new Intl.Locale(normalized).language;
+ } catch {
+ return normalized.split('-')[0]?.toLowerCase() || DEFAULT_TTS_LANGUAGE;
+ }
+}
+
+export function inferKokoroLanguageFromVoice(voice: string | null | undefined): string | null {
+ if (!voice?.trim()) return null;
+
+ const languages = new Set(
+ voice
+ .split('+')
+ .map((part) => part.trim().replace(/\([^)]*\)/g, ''))
+ .map((name) => KOKORO_LANGUAGE_BY_PREFIX[name.slice(0, 2).toLowerCase()])
+ .filter((language): language is string => Boolean(language)),
+ );
+
+ return languages.size === 1 ? [...languages][0] : null;
+}
+
+export function resolveTtsLanguage(input: {
+ configuredLanguage?: string | null;
+ voice?: string | null;
+}): string {
+ const configured = input.configuredLanguage?.trim();
+ if (configured && configured.toLowerCase() !== 'auto') {
+ return normalizeLanguageTag(configured);
+ }
+
+ return inferKokoroLanguageFromVoice(input.voice) ?? DEFAULT_TTS_LANGUAGE;
+}
+
+export function normalizeUnicodeToken(text: string): string {
+ return text
+ .normalize('NFKC')
+ .toLocaleLowerCase()
+ .replace(/[^\p{L}\p{N}\p{M}]+/gu, '');
+}
+
+export function segmentSentences(text: string, language?: string | null): string[] {
+ const normalizedLanguage = normalizeLanguageTag(language);
+ try {
+ return [...new Intl.Segmenter(normalizedLanguage, { granularity: 'sentence' }).segment(text)]
+ .map(({ segment }) => segment.trim())
+ .filter(Boolean);
+ } catch {
+ return text.split(/(?<=[.!?。!?؟।])\s*/u).map((segment) => segment.trim()).filter(Boolean);
+ }
+}
+
+export function segmentWords(text: string, language?: string | null): TextToken[] {
+ const normalizedLanguage = normalizeLanguageTag(language);
+ try {
+ return [...new Intl.Segmenter(normalizedLanguage, { granularity: 'word' }).segment(text)]
+ .filter((segment) => segment.isWordLike)
+ .map((segment) => ({
+ text: segment.segment,
+ start: segment.index,
+ end: segment.index + segment.segment.length,
+ }));
+ } catch {
+ const tokens: TextToken[] = [];
+ const wordRegex = /\S+/gu;
+ let match: RegExpExecArray | null;
+ while ((match = wordRegex.exec(text)) !== null) {
+ tokens.push({
+ text: match[0],
+ start: match.index,
+ end: match.index + match[0].length,
+ });
+ }
+ return tokens;
+ }
+}
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..fb517aa
--- /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 deuxieme 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..9cd68fe 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',
});
});
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..ad1f53c 100644
--- a/tests/unit/epub-word-highlight.vitest.spec.ts
+++ b/tests/unit/epub-word-highlight.vitest.spec.ts
@@ -28,6 +28,16 @@ const alignmentWords = (words: string[]): TTSSentenceAlignment['words'] =>
}));
describe('EPUB word highlight mapping', () => {
+ test('tokenizes Japanese and Chinese using locale-aware word boundaries', () => {
+ const japanese = tokenizeCanonicalSegment(segment('これは日本語です。', 5), 'ja');
+ expect(japanese.length).toBeGreaterThan(1);
+ expect(japanese.every((token) => token.norm.length > 0)).toBe(true);
+
+ const chinese = tokenizeCanonicalSegment(segment('这是中文。', 10), 'zh');
+ expect(chinese.length).toBeGreaterThan(1);
+ expect(chinese.map((token) => token.norm).join('')).toBe('这是中文');
+ });
+
test('tokenizes canonical segment words with source offsets', () => {
const tokens = tokenizeCanonicalSegment(segment('"Hello," she said.', 12));
diff --git a/tests/unit/language.vitest.spec.ts b/tests/unit/language.vitest.spec.ts
new file mode 100644
index 0000000..b224a2f
--- /dev/null
+++ b/tests/unit/language.vitest.spec.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from 'vitest';
+import { readFileSync } from 'fs';
+import { resolve } from 'path';
+
+import {
+ inferKokoroLanguageFromVoice,
+ normalizeUnicodeToken,
+ resolveTtsLanguage,
+ segmentSentences,
+ segmentWords,
+ toBaseLanguageCode,
+} from '../../src/lib/shared/language';
+
+describe('multilingual language utilities', () => {
+ test('keeps the multilingual document fixture readable as UTF-8', () => {
+ const fixture = readFileSync(resolve(process.cwd(), 'tests/files/multilingual-sample.txt'), 'utf8');
+ expect(fixture).toContain('これは二番目の文です。');
+ expect(fixture).toContain('这是第二句话。');
+ expect(fixture).toContain('هذه هي الجملة الثانية.');
+ });
+
+ test('infers Kokoro language from a single-language voice selection', () => {
+ expect(inferKokoroLanguageFromVoice('jf_alpha')).toBe('ja');
+ expect(inferKokoroLanguageFromVoice('zf_xiaobei(0.5)+zm_yunxi(0.5)')).toBe('zh-CN');
+ expect(inferKokoroLanguageFromVoice('ff_siwis+jf_alpha')).toBeNull();
+ });
+
+ test('prefers an explicit language and resolves regional tags for Whisper', () => {
+ expect(resolveTtsLanguage({ configuredLanguage: 'pt-BR', voice: 'jf_alpha' })).toBe('pt-BR');
+ expect(resolveTtsLanguage({ configuredLanguage: 'auto', voice: 'jf_alpha' })).toBe('ja');
+ expect(toBaseLanguageCode('zh-CN')).toBe('zh');
+ });
+
+ test('normalizes Unicode tokens without dropping non-Latin scripts', () => {
+ expect(normalizeUnicodeToken('École!')).toBe('école');
+ expect(normalizeUnicodeToken('日本語。')).toBe('日本語');
+ expect(normalizeUnicodeToken('العربية؟')).toBe('العربية');
+ expect(normalizeUnicodeToken('हिन्दी।')).toBe('हिन्दी');
+ });
+
+ test('segments CJK sentences and no-space language words', () => {
+ expect(segmentSentences('これは最初の文です。これは二番目の文です。', 'ja')).toEqual([
+ 'これは最初の文です。',
+ 'これは二番目の文です。',
+ ]);
+
+ const chineseWords = segmentWords('这是第一句话。', 'zh').map((token) => token.text);
+ expect(chineseWords.length).toBeGreaterThan(1);
+ expect(chineseWords.join('')).toBe('这是第一句话');
+ });
+
+ test('preserves source offsets for Thai word segmentation', () => {
+ const text = 'นี่คือประโยคแรก';
+ const words = segmentWords(text, 'th');
+ expect(words.length).toBeGreaterThan(1);
+ for (const word of words) {
+ expect(text.slice(word.start, word.end)).toBe(word.text);
+ }
+ });
+});
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/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);
+ }
+ });
});
From 696d3e488f60c1f48b0673f2a7670806806e359f Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 09:29:39 -0600
Subject: [PATCH 02/12] feat(tts): detect EPUB language and warn on voice
mismatch
---
src/app/(app)/epub/[id]/page.tsx | 6 +-
src/app/(app)/epub/[id]/useEpubDocument.ts | 22 ++++-
src/components/AudiobookExportModal.tsx | 12 ++-
src/components/documents/DocumentSettings.tsx | 22 ++++-
src/components/player/VoicesControl.tsx | 34 ++++++--
src/components/reader/SegmentsSidebar.tsx | 5 +-
src/lib/shared/language.ts | 82 +++++++++++++++++--
tests/unit/language.vitest.spec.ts | 48 +++++++++++
8 files changed, 212 insertions(+), 19 deletions(-)
diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx
index 3cc6ebd..38472f8 100644
--- a/src/app/(app)/epub/[id]/page.tsx
+++ b/src/app/(app)/epub/[id]/page.tsx
@@ -37,6 +37,7 @@ export default function EPUBPage() {
createFullAudioBook: createEPUBAudioBook,
regenerateChapter: regenerateEPUBChapter,
bookRef,
+ metadataLanguage,
} = epubState;
const { stop, setDocumentLanguage } = useTTS();
const { language, updateLanguage } = useDocumentLanguage(routeDocumentId);
@@ -109,8 +110,8 @@ export default function EPUBPage() {
useUnmountCleanupRef(clearCurrDoc);
useEffect(() => {
- setDocumentLanguage(language);
- }, [language, setDocumentLanguage]);
+ setDocumentLanguage(language === 'auto' ? metadataLanguage ?? 'auto' : language);
+ }, [language, metadataLanguage, setDocumentLanguage]);
// Compute available height = viewport - (header height + tts bar height)
useEffect(() => {
@@ -256,6 +257,7 @@ export default function EPUBPage() {
isOpen={activeSidebar === 'settings'}
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
language={language}
+ detectedLanguage={metadataLanguage}
onLanguageChange={(nextLanguage) => {
void updateLanguage(nextLanguage);
}}
diff --git a/src/app/(app)/epub/[id]/useEpubDocument.ts b/src/app/(app)/epub/[id]/useEpubDocument.ts
index 21e1f0a..14c38fa 100644
--- a/src/app/(app)/epub/[id]/useEpubDocument.ts
+++ b/src/app/(app)/epub/[id]/useEpubDocument.ts
@@ -44,6 +44,7 @@ import type {
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
import { isStableEpubLocator } from '@/types/client';
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
+import { normalizeOptionalLanguageTag } from '@/lib/shared/language';
export interface EpubDocumentState {
currDocData: ArrayBuffer | undefined;
@@ -51,6 +52,7 @@ export interface EpubDocumentState {
currDocPages: number | undefined;
currDocPage: number | string;
currDocText: string | undefined;
+ metadataLanguage: string | null;
isPlaybackReady: boolean;
setCurrentDocument: (id: string) => Promise;
clearCurrDoc: () => void;
@@ -116,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
@@ -172,6 +175,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
setCurrDocData(undefined);
setCurrDocName(undefined);
setCurrDocText(undefined);
+ setMetadataLanguage(null);
setIsPlaybackReady(false);
setCurrDocPages(undefined);
isEPUBSetOnce.current = false;
@@ -193,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();
@@ -380,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({
@@ -406,6 +424,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
currDocPages,
currDocPage,
currDocText,
+ metadataLanguage,
isPlaybackReady,
clearCurrDoc,
extractPageText,
@@ -432,6 +451,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
currDocPages,
currDocPage,
currDocText,
+ metadataLanguage,
isPlaybackReady,
clearCurrDoc,
extractPageText,
diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx
index 9595c46..b88efda 100644
--- a/src/components/AudiobookExportModal.tsx
+++ b/src/components/AudiobookExportModal.tsx
@@ -12,7 +12,7 @@ import { useTTS } from '@/contexts/TTSContext';
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
-import { resolveTtsLanguage } from '@/lib/shared/language';
+import { 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 {
@@ -132,6 +132,11 @@ export function AudiobookExportModal({
}),
};
}, [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) {
@@ -610,6 +615,11 @@ export function AudiobookExportModal({
)}
+ {languageWarnings.map((warning) => (
+
+ {warning}
+
+ ))}
{/* Speed controls */}
diff --git a/src/components/documents/DocumentSettings.tsx b/src/components/documents/DocumentSettings.tsx
index 0e1b0ca..7d4e6f9 100644
--- a/src/components/documents/DocumentSettings.tsx
+++ b/src/components/documents/DocumentSettings.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react';
import { useConfig, ViewType } from '@/contexts/ConfigContext';
+import { useTTS } from '@/contexts/TTSContext';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import {
SEGMENT_PRELOAD_DEPTH_MIN,
@@ -19,6 +20,7 @@ import { IconButton, RangeInput, Section, ToggleRow, CheckItem, SegmentedControl
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
+import { getLanguageDisplayName, getTtsLanguageCompatibilityWarnings } from '@/lib/shared/language';
const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [
{ kind: 'header', label: 'Header' },
@@ -106,12 +108,13 @@ function RangeSetting({
);
}
-export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, onLanguageChange, 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;
@@ -137,7 +140,14 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, onLa
epubWordHighlightEnabled,
htmlHighlightEnabled,
htmlWordHighlightEnabled,
+ ttsModel,
} = useConfig();
+ const { voice, resolvedLanguage } = useTTS();
+ const languageWarnings = getTtsLanguageCompatibilityWarnings({
+ model: ttsModel,
+ voice,
+ documentLanguage: resolvedLanguage,
+ });
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
const isPdfMode = !epub && !html && !!pdf;
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
@@ -189,6 +199,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, onLa
))}
+ {language === 'auto' && detectedLanguage ? (
+
+ Detected from document metadata: {getLanguageDisplayName(detectedLanguage)}
+
+ ) : null}
+ {languageWarnings.map((warning) => (
+
+ {warning}
+
+ ))}
) : null}
{isPdfMode && pdf && (
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/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx
index d9ff378..34b6f3b 100644
--- a/src/components/reader/SegmentsSidebar.tsx
+++ b/src/components/reader/SegmentsSidebar.tsx
@@ -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,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
playFromSegment,
activeReaderType,
clearSegmentCaches,
+ resolvedLanguage,
} = useTTS();
const {
providerRef,
@@ -348,7 +350,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;
diff --git a/src/lib/shared/language.ts b/src/lib/shared/language.ts
index b2e3437..9432235 100644
--- a/src/lib/shared/language.ts
+++ b/src/lib/shared/language.ts
@@ -1,3 +1,5 @@
+import { isKokoroModel } from '@/lib/shared/kokoro';
+
export const DEFAULT_TTS_LANGUAGE = 'en';
export interface TextToken {
@@ -26,6 +28,17 @@ const KOKORO_LANGUAGE_BY_PREFIX: Readonly> = {
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,
@@ -40,6 +53,17 @@ export function normalizeLanguageTag(
}
}
+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 {
@@ -50,17 +74,20 @@ export function toBaseLanguageCode(language: string | null | undefined): string
}
export function inferKokoroLanguageFromVoice(voice: string | null | undefined): string | null {
- if (!voice?.trim()) return null;
+ const languages = new Set(getKokoroVoiceLanguages(voice));
- const languages = new Set(
+ 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)),
- );
-
- return languages.size === 1 ? [...languages][0] : null;
+ ));
}
export function resolveTtsLanguage(input: {
@@ -75,6 +102,51 @@ export function resolveTtsLanguage(input: {
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)}.`,
+ );
+ }
+
+ if (voiceBaseLanguages.size > 1) {
+ warnings.push(
+ `Selected Kokoro voices use multiple languages (${voiceLanguages.map(getLanguageDisplayName).join(', ')}).`,
+ );
+ return warnings;
+ }
+
+ const voiceLanguage = voiceLanguages[0];
+ 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')
diff --git a/tests/unit/language.vitest.spec.ts b/tests/unit/language.vitest.spec.ts
index b224a2f..2842d80 100644
--- a/tests/unit/language.vitest.spec.ts
+++ b/tests/unit/language.vitest.spec.ts
@@ -3,7 +3,9 @@ import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
+ getTtsLanguageCompatibilityWarnings,
inferKokoroLanguageFromVoice,
+ normalizeOptionalLanguageTag,
normalizeUnicodeToken,
resolveTtsLanguage,
segmentSentences,
@@ -25,6 +27,52 @@ describe('multilingual language utilities', () => {
expect(inferKokoroLanguageFromVoice('ff_siwis+jf_alpha')).toBeNull();
});
+ 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([
+ 'Selected Kokoro voices use multiple languages (French, Japanese).',
+ ]);
+
+ 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');
From facbda247749c0d0de1bfc0032024f7dfc549296 Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 09:38:14 -0600
Subject: [PATCH 03/12] feat(tts): forward language to capable providers
---
src/app/api/audiobook/chapter/route.ts | 1 +
src/app/api/tts/segments/ensure/route.ts | 1 +
src/lib/server/tts/generate.ts | 85 +++++++++++++++++--
src/lib/server/tts/voice-resolution.ts | 64 ++++++++++++++
tests/unit/tts-generate.vitest.spec.ts | 32 ++++++-
.../unit/tts-provider-catalog.vitest.spec.ts | 50 ++++++++++-
6 files changed, 226 insertions(+), 7 deletions(-)
diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts
index 31f40e6..e63d045 100644
--- a/src/app/api/audiobook/chapter/route.ts
+++ b/src/app/api/audiobook/chapter/route.ts
@@ -597,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 e892784..f089231 100644
--- a/src/app/api/tts/segments/ensure/route.ts
+++ b/src/app/api/tts/segments/ensure/route.ts
@@ -642,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,
diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts
index 01a7978..c235e88 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.baseUrl || ''}|${request.model as string}`;
+ 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/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts
index d42ab3e..1fd9420 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' || !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/tests/unit/tts-generate.vitest.spec.ts b/tests/unit/tts-generate.vitest.spec.ts
index a601952..4b35d02 100644
--- a/tests/unit/tts-generate.vitest.spec.ts
+++ b/tests/unit/tts-generate.vitest.spec.ts
@@ -1,6 +1,10 @@
import { describe, expect, test } from 'vitest';
-import { extractReplicateAudioUrl } from '../../src/lib/server/tts/generate';
+import {
+ buildTTSCacheKey,
+ extractReplicateAudioUrl,
+ resolveReplicateLanguageValue,
+} from '../../src/lib/server/tts/generate';
describe('replicate output URL extraction', () => {
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', () => {
From 3f15636e0e948325dc06e089c316688cecc902e3 Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 10:37:55 -0600
Subject: [PATCH 04/12] fix(highlight): support Japanese token matching
---
src/hooks/epub/useEPUBHighlighting.ts | 35 ++++++
src/lib/client/epub/epub-word-highlight.ts | 16 ++-
src/lib/client/pdf-highlight-match.ts | 101 +++++++++++++++
src/lib/client/pdf-highlight-worker.ts | 116 ++----------------
src/lib/client/pdf.ts | 9 +-
tests/unit/epub-word-highlight.vitest.spec.ts | 30 +++++
tests/unit/pdf-highlight-match.vitest.spec.ts | 29 +++++
7 files changed, 223 insertions(+), 113 deletions(-)
create mode 100644 src/lib/client/pdf-highlight-match.ts
create mode 100644 tests/unit/pdf-highlight-match.vitest.spec.ts
diff --git a/src/hooks/epub/useEPUBHighlighting.ts b/src/hooks/epub/useEPUBHighlighting.ts
index 8c31d34..83beafa 100644
--- a/src/hooks/epub/useEPUBHighlighting.ts
+++ b/src/hooks/epub/useEPUBHighlighting.ts
@@ -6,6 +6,7 @@ import type { Rendition } from 'epubjs';
import {
buildMonotonicWordToTokenMap,
buildWordHighlightCacheKey,
+ resolveAlignmentWordSourceRange,
tokenizeCanonicalSegment,
type EpubCanonicalWordToken,
} from '@/lib/client/epub/epub-word-highlight';
@@ -118,6 +119,40 @@ export function useEPUBHighlighting({
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return;
+ 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, language);
diff --git a/src/lib/client/epub/epub-word-highlight.ts b/src/lib/client/epub/epub-word-highlight.ts
index 5e90cda..7007ce5 100644
--- a/src/lib/client/epub/epub-word-highlight.ts
+++ b/src/lib/client/epub/epub-word-highlight.ts
@@ -1,5 +1,5 @@
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
-import type { TTSSentenceAlignment } from '@/types/tts';
+import type { TTSSentenceAlignment, TTSSentenceWord } from '@/types/tts';
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
export type EpubCanonicalWordToken = {
@@ -11,6 +11,20 @@ export type EpubCanonicalWordToken = {
export const normalizeWordForHighlight = (text: string): string =>
normalizeUnicodeToken(text);
+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;
+
+ return {
+ sourceStart: segment.startAnchor.offset + charStart,
+ sourceEnd: segment.startAnchor.offset + charEnd,
+ };
+};
+
export const tokenizeCanonicalSegment = (
segment: CanonicalTtsSegment,
language?: string,
diff --git a/src/lib/client/pdf-highlight-match.ts b/src/lib/client/pdf-highlight-match.ts
new file mode 100644
index 0000000..b16ef5d
--- /dev/null
+++ b/src/lib/client/pdf-highlight-match.ts
@@ -0,0 +1,101 @@
+import { CmpStr } from 'cmpstr';
+
+const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
+
+export interface HighlightTokenMatchResult {
+ bestStart: number;
+ bestEnd: number;
+ rating: number;
+ lengthDiff: number;
+}
+
+export function findBestHighlightTokenMatch(
+ patternTokens: string[],
+ tokenTexts: string[],
+): HighlightTokenMatchResult {
+ const cleanPatternTokens = patternTokens.map((token) => token.trim()).filter(Boolean);
+ const cleanPattern = cleanPatternTokens.join('');
+ const patternLen = cleanPattern.length;
+ const responseBase: HighlightTokenMatchResult = {
+ bestStart: -1,
+ bestEnd: -1,
+ rating: 0,
+ lengthDiff: Number.POSITIVE_INFINITY,
+ };
+
+ if (!patternLen || !tokenTexts.length) return responseBase;
+
+ const patternTokenCount = cleanPatternTokens.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 < tokenTexts.length; start += 1) {
+ let combined = '';
+
+ for (
+ let offset = 0;
+ offset < maxWindowTokens && start + offset < tokenTexts.length;
+ offset += 1
+ ) {
+ const token = tokenTexts[start + offset];
+ combined += 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);
+
+ let boostedRating = adjustedRating;
+ const windowTokens = tokenTexts.slice(start, start + windowSize);
+ const maxPrefixCheck = Math.min(
+ windowTokens.length,
+ cleanPatternTokens.length,
+ 5,
+ );
+
+ let prefixMatches = 0;
+ for (let i = 0; i < maxPrefixCheck; i += 1) {
+ const tokenSim = cmp.compare(windowTokens[i], cleanPatternTokens[i]);
+ if (tokenSim < 0.8) break;
+ prefixMatches += 1;
+ }
+
+ if (prefixMatches > 0) {
+ const prefixRatio = prefixMatches / maxPrefixCheck;
+ boostedRating = adjustedRating * (1 + prefixRatio * 0.25);
+ }
+
+ if (
+ boostedRating > bestRating
+ || (
+ Math.abs(boostedRating - bestRating) < 1e-3
+ && lengthDiff < bestLengthDiff
+ )
+ ) {
+ bestRating = boostedRating;
+ bestLengthDiff = lengthDiff;
+ bestStart = start;
+ bestEnd = start + offset;
+ }
+ }
+ }
+
+ return {
+ bestStart,
+ bestEnd,
+ rating: bestRating,
+ lengthDiff: bestLengthDiff,
+ };
+}
diff --git a/src/lib/client/pdf-highlight-worker.ts b/src/lib/client/pdf-highlight-worker.ts
index 5b41d45..6b396af 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 './pdf-highlight-match';
interface TokenMatchRequest {
id: string;
type: 'tokenMatch';
- pattern: string;
+ patternTokens: string[];
tokenTexts: string[];
}
@@ -39,112 +37,14 @@ 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',
+ ...result,
};
(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 32a1a7e..b81234d 100644
--- a/src/lib/client/pdf.ts
+++ b/src/lib/client/pdf.ts
@@ -13,7 +13,7 @@ const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
interface HighlightTokenMatchRequest {
id: string;
type: 'tokenMatch';
- pattern: string;
+ patternTokens: string[];
tokenTexts: string[];
}
@@ -46,7 +46,7 @@ function getHighlightWorker(): Worker | null {
}
function runHighlightTokenMatch(
- pattern: string,
+ patternTokens: string[],
tokenTexts: string[]
): Promise {
const worker = getHighlightWorker();
@@ -71,7 +71,7 @@ function runHighlightTokenMatch(
const message: HighlightTokenMatchRequest = {
id,
type: 'tokenMatch',
- pattern,
+ patternTokens,
tokenTexts,
};
worker.postMessage(message);
@@ -612,9 +612,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) {
diff --git a/tests/unit/epub-word-highlight.vitest.spec.ts b/tests/unit/epub-word-highlight.vitest.spec.ts
index ad1f53c..20f0266 100644
--- a/tests/unit/epub-word-highlight.vitest.spec.ts
+++ b/tests/unit/epub-word-highlight.vitest.spec.ts
@@ -2,6 +2,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';
@@ -38,6 +39,35 @@ describe('EPUB word highlight mapping', () => {
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));
diff --git a/tests/unit/pdf-highlight-match.vitest.spec.ts b/tests/unit/pdf-highlight-match.vitest.spec.ts
new file mode 100644
index 0000000..716fc26
--- /dev/null
+++ b/tests/unit/pdf-highlight-match.vitest.spec.ts
@@ -0,0 +1,29 @@
+import { describe, expect, test } from 'vitest';
+
+import { findBestHighlightTokenMatch } from '../../src/lib/client/pdf-highlight-match';
+import { segmentWords } from '../../src/lib/shared/language';
+
+describe('PDF highlight token matching', () => {
+ 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({
+ bestStart: 1,
+ bestEnd: patternTokens.length,
+ lengthDiff: 0,
+ });
+ });
+
+ test('matches spaced Latin text without relying on whitespace tokens', () => {
+ expect(findBestHighlightTokenMatch(
+ ['hello', 'world'],
+ ['before', 'hello', 'world', 'after'],
+ )).toMatchObject({
+ bestStart: 1,
+ bestEnd: 2,
+ lengthDiff: 0,
+ });
+ });
+});
From 81bd355bd1d225f485e5a84f569a996db0f2d9ec Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 10:45:19 -0600
Subject: [PATCH 05/12] refactor(highlight): share multilingual token alignment
---
src/hooks/epub/useEPUBHighlighting.ts | 30 ++-
src/lib/client/epub/epub-word-highlight.ts | 69 +-----
src/lib/client/highlight-token-alignment.ts | 232 ++++++++++++++++++
src/lib/client/html/highlight.ts | 187 ++------------
src/lib/client/pdf-highlight-match.ts | 101 --------
src/lib/client/pdf-highlight-worker.ts | 7 +-
src/lib/client/pdf.ts | 203 ++++-----------
tests/unit/epub-word-highlight.vitest.spec.ts | 29 ---
.../highlight-token-alignment.vitest.spec.ts | 92 +++++++
tests/unit/pdf-highlight-match.vitest.spec.ts | 29 ---
10 files changed, 432 insertions(+), 547 deletions(-)
create mode 100644 src/lib/client/highlight-token-alignment.ts
delete mode 100644 src/lib/client/pdf-highlight-match.ts
create mode 100644 tests/unit/highlight-token-alignment.vitest.spec.ts
delete mode 100644 tests/unit/pdf-highlight-match.vitest.spec.ts
diff --git a/src/hooks/epub/useEPUBHighlighting.ts b/src/hooks/epub/useEPUBHighlighting.ts
index 83beafa..7362d3e 100644
--- a/src/hooks/epub/useEPUBHighlighting.ts
+++ b/src/hooks/epub/useEPUBHighlighting.ts
@@ -4,12 +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,
@@ -20,7 +23,7 @@ import type { TTSSentenceAlignment } from '@/types/tts';
export type EpubWordHighlightMapCache = {
key: string;
- wordToToken: number[];
+ wordToTokenRange: Array;
tokens: EpubCanonicalWordToken[];
};
@@ -159,19 +162,28 @@ export function useEPUBHighlighting({
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 {
diff --git a/src/lib/client/epub/epub-word-highlight.ts b/src/lib/client/epub/epub-word-highlight.ts
index 7007ce5..de44021 100644
--- a/src/lib/client/epub/epub-word-highlight.ts
+++ b/src/lib/client/epub/epub-word-highlight.ts
@@ -1,6 +1,7 @@
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
import type { TTSSentenceAlignment, TTSSentenceWord } from '@/types/tts';
-import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
+import { segmentWords } from '@/lib/shared/language';
+import { normalizeHighlightToken } from '@/lib/client/highlight-token-alignment';
export type EpubCanonicalWordToken = {
norm: string;
@@ -9,7 +10,7 @@ export type EpubCanonicalWordToken = {
};
export const normalizeWordForHighlight = (text: string): string =>
- normalizeUnicodeToken(text);
+ normalizeHighlightToken(text);
export const resolveAlignmentWordSourceRange = (
segment: CanonicalTtsSegment,
@@ -37,70 +38,6 @@ export const tokenizeCanonicalSegment = (
}))
.filter((token) => Boolean(token.norm));
-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 buildWordHighlightCacheKey = (
segment: CanonicalTtsSegment,
alignment: TTSSentenceAlignment,
diff --git a/src/lib/client/highlight-token-alignment.ts b/src/lib/client/highlight-token-alignment.ts
new file mode 100644
index 0000000..26210ad
--- /dev/null
+++ b/src/lib/client/highlight-token-alignment.ts
@@ -0,0 +1,232 @@
+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;
+
+ 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 db26cc2..a78a70a 100644
--- a/src/lib/client/html/highlight.ts
+++ b/src/lib/client/html/highlight.ts
@@ -11,14 +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 { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
+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';
@@ -30,8 +34,6 @@ interface DomToken {
norm: string;
}
-const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
-
let sentenceWraps: HTMLSpanElement[] = [];
let wordWraps: HTMLSpanElement[] = [];
@@ -46,15 +48,15 @@ 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 normalizeUnicodeToken(word);
+ return normalizeHighlightToken(word);
}
function tokenizePattern(pattern: string, language?: string): string[] {
@@ -171,43 +173,9 @@ function collectTokensInsideWraps(wraps: HTMLSpanElement[], language?: string):
}
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[] {
@@ -274,116 +242,11 @@ export function highlightHtmlSentence(
sentence,
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,
@@ -403,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-match.ts b/src/lib/client/pdf-highlight-match.ts
deleted file mode 100644
index b16ef5d..0000000
--- a/src/lib/client/pdf-highlight-match.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { CmpStr } from 'cmpstr';
-
-const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
-
-export interface HighlightTokenMatchResult {
- bestStart: number;
- bestEnd: number;
- rating: number;
- lengthDiff: number;
-}
-
-export function findBestHighlightTokenMatch(
- patternTokens: string[],
- tokenTexts: string[],
-): HighlightTokenMatchResult {
- const cleanPatternTokens = patternTokens.map((token) => token.trim()).filter(Boolean);
- const cleanPattern = cleanPatternTokens.join('');
- const patternLen = cleanPattern.length;
- const responseBase: HighlightTokenMatchResult = {
- bestStart: -1,
- bestEnd: -1,
- rating: 0,
- lengthDiff: Number.POSITIVE_INFINITY,
- };
-
- if (!patternLen || !tokenTexts.length) return responseBase;
-
- const patternTokenCount = cleanPatternTokens.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 < tokenTexts.length; start += 1) {
- let combined = '';
-
- for (
- let offset = 0;
- offset < maxWindowTokens && start + offset < tokenTexts.length;
- offset += 1
- ) {
- const token = tokenTexts[start + offset];
- combined += 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);
-
- let boostedRating = adjustedRating;
- const windowTokens = tokenTexts.slice(start, start + windowSize);
- const maxPrefixCheck = Math.min(
- windowTokens.length,
- cleanPatternTokens.length,
- 5,
- );
-
- let prefixMatches = 0;
- for (let i = 0; i < maxPrefixCheck; i += 1) {
- const tokenSim = cmp.compare(windowTokens[i], cleanPatternTokens[i]);
- if (tokenSim < 0.8) break;
- prefixMatches += 1;
- }
-
- if (prefixMatches > 0) {
- const prefixRatio = prefixMatches / maxPrefixCheck;
- boostedRating = adjustedRating * (1 + prefixRatio * 0.25);
- }
-
- if (
- boostedRating > bestRating
- || (
- Math.abs(boostedRating - bestRating) < 1e-3
- && lengthDiff < bestLengthDiff
- )
- ) {
- bestRating = boostedRating;
- bestLengthDiff = lengthDiff;
- bestStart = start;
- bestEnd = start + offset;
- }
- }
- }
-
- return {
- bestStart,
- bestEnd,
- rating: bestRating,
- lengthDiff: bestLengthDiff,
- };
-}
diff --git a/src/lib/client/pdf-highlight-worker.ts b/src/lib/client/pdf-highlight-worker.ts
index 6b396af..fa3fbd6 100644
--- a/src/lib/client/pdf-highlight-worker.ts
+++ b/src/lib/client/pdf-highlight-worker.ts
@@ -1,6 +1,6 @@
///
-import { findBestHighlightTokenMatch } from './pdf-highlight-match';
+import { findBestHighlightTokenMatch } from './highlight-token-alignment';
interface TokenMatchRequest {
id: string;
@@ -43,7 +43,10 @@ self.onmessage = (event: MessageEvent) => {
const response: TokenMatchResponse = {
id,
type: 'tokenMatchResult',
- ...result,
+ bestStart: result.start,
+ bestEnd: result.end,
+ rating: result.rating,
+ lengthDiff: result.lengthDiff,
};
(self as unknown as DedicatedWorkerGlobalScope).postMessage(response);
diff --git a/src/lib/client/pdf.ts b/src/lib/client/pdf.ts
index b81234d..1293957 100644
--- a/src/lib/client/pdf.ts
+++ b/src/lib/client/pdf.ts
@@ -3,11 +3,12 @@ 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';
-import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
-
-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 {
@@ -152,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;
@@ -181,9 +182,6 @@ function getOrCreateHighlightLayer(span: HTMLElement): {
return { layer, pageElement, pageRect: pageElement.getBoundingClientRect() };
}
-const normalizeWordForMatch = (text: string): string =>
- normalizeUnicodeToken(text);
-
// Highlighting functions
let highlightPatternSeq = 0;
@@ -421,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;
@@ -672,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/tests/unit/epub-word-highlight.vitest.spec.ts b/tests/unit/epub-word-highlight.vitest.spec.ts
index 20f0266..6d0e3cc 100644
--- a/tests/unit/epub-word-highlight.vitest.spec.ts
+++ b/tests/unit/epub-word-highlight.vitest.spec.ts
@@ -1,7 +1,6 @@
import { describe, expect, test } from 'vitest';
import {
- buildMonotonicWordToTokenMap,
resolveAlignmentWordSourceRange,
tokenizeCanonicalSegment,
} from '../../src/lib/client/epub/epub-word-highlight';
@@ -19,15 +18,6 @@ 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');
@@ -78,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..7ee3212
--- /dev/null
+++ b/tests/unit/highlight-token-alignment.vitest.spec.ts
@@ -0,0 +1,92 @@
+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('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/pdf-highlight-match.vitest.spec.ts b/tests/unit/pdf-highlight-match.vitest.spec.ts
deleted file mode 100644
index 716fc26..0000000
--- a/tests/unit/pdf-highlight-match.vitest.spec.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-import { describe, expect, test } from 'vitest';
-
-import { findBestHighlightTokenMatch } from '../../src/lib/client/pdf-highlight-match';
-import { segmentWords } from '../../src/lib/shared/language';
-
-describe('PDF highlight token matching', () => {
- 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({
- bestStart: 1,
- bestEnd: patternTokens.length,
- lengthDiff: 0,
- });
- });
-
- test('matches spaced Latin text without relying on whitespace tokens', () => {
- expect(findBestHighlightTokenMatch(
- ['hello', 'world'],
- ['before', 'hello', 'world', 'after'],
- )).toMatchObject({
- bestStart: 1,
- bestEnd: 2,
- lengthDiff: 0,
- });
- });
-});
From 112dbc0da9fde5f2ee622024fa5fb105ce84fa19 Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 10:50:05 -0600
Subject: [PATCH 06/12] fix(voices): prevent mixed-language Kokoro selections
---
src/components/player/VoicesControlBase.tsx | 26 ++++++++++++++-------
src/lib/shared/language.ts | 24 ++++++++++++-------
tests/unit/language.vitest.spec.ts | 22 ++++++++++++++---
3 files changed, 53 insertions(+), 19 deletions(-)
diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx
index fd2cad9..b314902 100644
--- a/src/components/player/VoicesControlBase.tsx
+++ b/src/components/player/VoicesControlBase.tsx
@@ -6,6 +6,7 @@ import {
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useState } 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';
@@ -70,8 +71,13 @@ export function VoicesControlBase({
if (initial.length > maxVoices) {
initial = initial.slice(0, maxVoices);
}
+ initial = keepKokoroVoicesInOneLanguage(initial);
setSelectedVoices(initial);
- }, [isKokoro, maxVoices, voice, availableVoices]);
+ const combined = buildKokoroVoiceString(initial);
+ if (combined && combined !== voice) {
+ onChangeVoice(combined);
+ }
+ }, [isKokoro, maxVoices, voice, availableVoices, onChangeVoice]);
const currentVoice = useMemo(() => {
if (isKokoro && maxVoices > 1) {
@@ -101,15 +107,19 @@ 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);
}
}
diff --git a/src/lib/shared/language.ts b/src/lib/shared/language.ts
index 9432235..b278bd0 100644
--- a/src/lib/shared/language.ts
+++ b/src/lib/shared/language.ts
@@ -90,6 +90,21 @@ export function getKokoroVoiceLanguages(voice: string | null | undefined): strin
));
}
+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;
@@ -130,14 +145,7 @@ export function getTtsLanguageCompatibilityWarnings(input: {
);
}
- if (voiceBaseLanguages.size > 1) {
- warnings.push(
- `Selected Kokoro voices use multiple languages (${voiceLanguages.map(getLanguageDisplayName).join(', ')}).`,
- );
- return warnings;
- }
-
- const voiceLanguage = voiceLanguages[0];
+ 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)}.`,
diff --git a/tests/unit/language.vitest.spec.ts b/tests/unit/language.vitest.spec.ts
index 2842d80..b50552c 100644
--- a/tests/unit/language.vitest.spec.ts
+++ b/tests/unit/language.vitest.spec.ts
@@ -5,6 +5,7 @@ import { resolve } from 'path';
import {
getTtsLanguageCompatibilityWarnings,
inferKokoroLanguageFromVoice,
+ keepKokoroVoicesInOneLanguage,
normalizeOptionalLanguageTag,
normalizeUnicodeToken,
resolveTtsLanguage,
@@ -27,6 +28,23 @@ describe('multilingual language utilities', () => {
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');
@@ -53,9 +71,7 @@ describe('multilingual language utilities', () => {
model: 'kokoro',
voice: 'ff_siwis+jf_alpha',
documentLanguage: 'fr',
- })).toEqual([
- 'Selected Kokoro voices use multiple languages (French, Japanese).',
- ]);
+ })).toEqual([]);
expect(getTtsLanguageCompatibilityWarnings({
model: 'kokoro',
From 8071ae1a61ca3ec4919a202f9d11a5daa21c5f66 Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 11:05:36 -0600
Subject: [PATCH 07/12] refactor(ui): centralize standard select controls
---
src/components/AudiobookExportModal.tsx | 64 +++-----
src/components/SettingsModal.tsx | 144 +++++-------------
src/components/admin/AdminFeaturesPanel.tsx | 55 ++-----
src/components/admin/AdminProvidersPanel.tsx | 127 +++++----------
src/components/documents/DocumentSettings.tsx | 32 ++--
src/components/ui/select.tsx | 107 +++++++++----
.../shared-select-consumers.vitest.spec.ts | 31 ++++
7 files changed, 235 insertions(+), 325 deletions(-)
create mode 100644 tests/unit/shared-select-consumers.vitest.spec.ts
diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx
index b88efda..254ac1f 100644
--- a/src/components/AudiobookExportModal.tsx
+++ b/src/components/AudiobookExportModal.tsx
@@ -1,11 +1,11 @@
'use client';
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
-import { Transition, Listbox, Menu, MenuButton } from '@headlessui/react';
+import { Transition, Menu, MenuButton } from '@headlessui/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';
@@ -14,7 +14,7 @@ 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, RangeInput, Select } from '@/components/ui';
import {
getAudiobookStatus,
deleteAudiobookChapter,
@@ -565,51 +565,25 @@ 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()}
)}
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
index a5ad457..c15bec2 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';
@@ -52,10 +48,8 @@ import {
Input,
ModalFrame,
ModalTitle,
+ Select,
inputClass,
- SharedListboxButton,
- SharedListboxOption,
- SharedListboxOptions,
} 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 && (
@@ -768,8 +731,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 +763,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' && (
{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.
diff --git a/src/components/admin/AdminProvidersPanel.tsx b/src/components/admin/AdminProvidersPanel.tsx
index 55dfc0f..f549790 100644
--- a/src/components/admin/AdminProvidersPanel.tsx
+++ b/src/components/admin/AdminProvidersPanel.tsx
@@ -1,11 +1,11 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { Listbox, Menu, MenuButton, Transition } from '@headlessui/react';
+import { 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 { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import {
@@ -14,9 +14,7 @@ import {
Section,
ToggleRow,
inputClass,
- SharedListboxButton,
- SharedListboxOption,
- SharedListboxOptions,
+ Select,
Button,
IconButton,
Input,
@@ -410,8 +408,17 @@ export function AdminProvidersPanel() {
/>
- 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({
@@ -423,49 +430,29 @@ export function AdminProvidersPanel() {
});
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 +463,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' && (
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);
@@ -183,22 +193,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, dete
subtitle="Controls sentence splitting and synchronized word alignment."
variant="flat"
>
-
+
Document language
- onLanguageChange(event.target.value)}
- className="w-full rounded-md border border-offbase bg-surface-solid px-3 py-2 text-sm text-foreground"
- >
- {DOCUMENT_LANGUAGE_OPTIONS.map((option) => (
-
- {option.label}
-
- ))}
-
-
+ onLanguageChange(option.value)}
+ options={DOCUMENT_LANGUAGE_OPTIONS}
+ />
+
{language === 'auto' && detectedLanguage ? (
Detected from document metadata: {getLanguageDisplayName(detectedLanguage)}
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/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('
Date: Sat, 6 Jun 2026 11:15:48 -0600
Subject: [PATCH 08/12] refactor(ui): enforce shared control primitives
---
src/app/(app)/signin/page.tsx | 13 ++----
src/app/(app)/signup/page.tsx | 9 ++--
src/components/AudiobookExportModal.tsx | 25 ++++-------
src/components/ColorPicker.tsx | 11 +++--
src/components/PrivacyModal.tsx | 6 +--
src/components/SettingsModal.tsx | 9 ++--
src/components/admin/AdminFeaturesPanel.tsx | 45 +++++++------------
src/components/admin/AdminProvidersPanel.tsx | 36 +++++----------
.../doclist/window/FinderSidebar.tsx | 25 ++++-------
.../documents/DocumentHeaderMenu.tsx | 24 +++-------
.../documents/DocumentSelectionModal.tsx | 10 ++---
src/components/player/Navigator.tsx | 7 ++-
src/components/player/SpeedControl.tsx | 7 ++-
src/components/reader/SegmentsSidebar.tsx | 13 +++---
src/components/ui/button.tsx | 32 ++++++++-----
src/components/ui/checkbox.tsx | 16 +++++++
src/components/ui/index.ts | 1 +
src/components/ui/input.tsx | 4 +-
src/components/ui/menu.tsx | 32 +++++++++----
src/components/ui/popover.tsx | 18 +++++++-
...ared-form-control-consumers.vitest.spec.ts | 43 ++++++++++++++++++
.../shared-overlay-consumers.vitest.spec.ts | 31 +++++++++++++
22 files changed, 234 insertions(+), 183 deletions(-)
create mode 100644 src/components/ui/checkbox.tsx
create mode 100644 tests/unit/shared-form-control-consumers.vitest.spec.ts
create mode 100644 tests/unit/shared-overlay-consumers.vitest.spec.ts
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/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx
index 254ac1f..a50a928 100644
--- a/src/components/AudiobookExportModal.tsx
+++ b/src/components/AudiobookExportModal.tsx
@@ -1,7 +1,6 @@
'use client';
-import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
-import { Transition, 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';
@@ -14,7 +13,7 @@ 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, Select } from '@/components/ui';
+import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, RangeInput, Select } from '@/components/ui';
import {
getAudiobookStatus,
deleteAudiobookChapter,
@@ -762,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 c15bec2..b9465ea 100644
--- a/src/components/SettingsModal.tsx
+++ b/src/components/SettingsModal.tsx
@@ -46,10 +46,10 @@ import {
ChoiceTile,
IconButton,
Input,
+ Textarea,
ModalFrame,
ModalTitle,
Select,
- inputClass,
} from '@/components/ui';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -697,7 +697,6 @@ export function SettingsModal({
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
- className={inputClass}
/>
)}
@@ -713,7 +712,6 @@ export function SettingsModal({
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder="Using environment variable"
- className={inputClass}
/>
)}
@@ -774,7 +772,6 @@ export function SettingsModal({
setModelValue(e.target.value);
}}
placeholder="Enter custom model name"
- className={inputClass}
/>
)}
@@ -783,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 8c299a5..118c594 100644
--- a/src/components/admin/AdminFeaturesPanel.tsx
+++ b/src/components/admin/AdminFeaturesPanel.tsx
@@ -7,9 +7,9 @@ import {
Badge,
Section,
ToggleRow,
- inputClass,
Select,
Button,
+ Input,
} from '@/components/ui';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
@@ -274,11 +274,10 @@ export function AdminFeaturesPanel() {
Anonymous per-user daily limit
{renderSource('ttsDailyLimitAnonymous')}
- updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
/>
@@ -288,11 +287,10 @@ export function AdminFeaturesPanel() {
Authenticated per-user daily limit
{renderSource('ttsDailyLimitAuthenticated')}
- updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
/>
@@ -302,11 +300,10 @@ export function AdminFeaturesPanel() {
Anonymous IP daily backstop
{renderSource('ttsIpDailyLimitAnonymous')}
- updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
/>
@@ -316,11 +313,10 @@ export function AdminFeaturesPanel() {
Authenticated IP daily backstop
{renderSource('ttsIpDailyLimitAuthenticated')}
- updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
/>
@@ -343,11 +339,10 @@ export function AdminFeaturesPanel() {
Burst limit (parses)
{renderSource('computeParseBurstMax')}
- updatePositiveIntDraft('computeParseBurstMax', event.target.value)}
/>
@@ -357,11 +352,10 @@ export function AdminFeaturesPanel() {
Burst window (seconds)
{renderSource('computeParseBurstWindowSec')}
- updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)}
/>
@@ -371,11 +365,10 @@ export function AdminFeaturesPanel() {
Sustained limit (parses)
{renderSource('computeParseSustainedMax')}
- updatePositiveIntDraft('computeParseSustainedMax', event.target.value)}
/>
@@ -385,11 +378,10 @@ export function AdminFeaturesPanel() {
Sustained window (seconds)
{renderSource('computeParseSustainedWindowSec')}
- updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
/>
@@ -405,13 +397,13 @@ export function AdminFeaturesPanel() {
{renderSource('maxUploadMb')}
- updatePositiveIntDraft('maxUploadMb', event.target.value)}
/>
@@ -436,9 +428,8 @@ export function AdminFeaturesPanel() {
{renderSource('changelogFeedUrl')}
- updateDraft('changelogFeedUrl', event.target.value)}
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
@@ -481,11 +472,10 @@ export function AdminFeaturesPanel() {
Retry attempts
{renderSource('ttsUpstreamMaxRetries')}
- updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)}
/>
@@ -495,11 +485,10 @@ export function AdminFeaturesPanel() {
Upstream timeout (ms)
{renderSource('ttsUpstreamTimeoutMs')}
- updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)}
/>
@@ -509,11 +498,10 @@ export function AdminFeaturesPanel() {
Audio cache size (bytes)
{renderSource('ttsCacheMaxSizeBytes')}
- updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)}
/>
@@ -523,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 f549790..ef92a1d 100644
--- a/src/components/admin/AdminProvidersPanel.tsx
+++ b/src/components/admin/AdminProvidersPanel.tsx
@@ -1,8 +1,6 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { 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 { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
@@ -13,13 +11,16 @@ import {
Field,
Section,
ToggleRow,
- inputClass,
Select,
Button,
IconButton,
Input,
+ Textarea,
MenuItemsSurface,
MenuActionItem,
+ MenuRoot,
+ MenuTrigger,
+ MenuTransition,
} from '@/components/ui';
type ProviderType = TtsProviderId;
@@ -394,7 +395,6 @@ export function AdminProvidersPanel() {
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
placeholder="kokoro-prod"
- className={inputClass}
disabled={isEditingExisting}
/>
@@ -404,7 +404,6 @@ export function AdminProvidersPanel() {
value={form.displayName}
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
placeholder="Kokoro (production)"
- className={inputClass}
/>
@@ -483,7 +482,6 @@ export function AdminProvidersPanel() {
});
}}
placeholder="Enter custom model id"
- className={inputClass}
/>
)}
@@ -494,11 +492,11 @@ export function AdminProvidersPanel() {
className="sm:col-span-2"
hint="Optional. Applied by default when this shared provider is selected."
>
-
)}
@@ -509,7 +507,6 @@ export function AdminProvidersPanel() {
value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
placeholder={baseUrlPlaceholder}
- className={inputClass}
/>
)}
@@ -523,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}
/>
@@ -584,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 && (
-
+
);
};
diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx
index 34b6f3b..835fe98 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';
@@ -888,15 +888,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/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');
+ });
+});
From fed782eb0bfd0b27f15341b8ade85b3d8252114c Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 11:32:41 -0600
Subject: [PATCH 09/12] docs(intro): update documentation for multilingual
support
Expand README and introduction to highlight multilingual features,
including language-aware narration and highlighting. Update landing
page to reflect multilingual capabilities in descriptions, keywords,
and UI labels.
---
README.md | 3 ++-
docs-site/docs/introduction.md | 5 ++++-
src/app/(public)/page.tsx | 21 +++++++++++----------
3 files changed, 17 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index 6ded1a6..f6a5b48 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..a255ce3 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/(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.
From 4b226858b1cfca70c9f27dd589e91e6139577c6d Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 11:41:47 -0600
Subject: [PATCH 10/12] perf(highlight): optimize exact token sequence matching
for large documents
Add a fast linear scan to resolve exact token sequence highlights before
falling back to the fuzzy window search, significantly improving
performance for large documents. Update unit tests to verify that
exact matches are found efficiently without invoking expensive
comparisons.
---
src/lib/client/highlight-token-alignment.ts | 23 +++++++++++++++++++
.../highlight-token-alignment.vitest.spec.ts | 15 ++++++++++++
2 files changed, 38 insertions(+)
diff --git a/src/lib/client/highlight-token-alignment.ts b/src/lib/client/highlight-token-alignment.ts
index 26210ad..dd560bf 100644
--- a/src/lib/client/highlight-token-alignment.ts
+++ b/src/lib/client/highlight-token-alignment.ts
@@ -36,6 +36,29 @@ export function findBestHighlightTokenMatch(
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(
diff --git a/tests/unit/highlight-token-alignment.vitest.spec.ts b/tests/unit/highlight-token-alignment.vitest.spec.ts
index 7ee3212..32fc7d5 100644
--- a/tests/unit/highlight-token-alignment.vitest.spec.ts
+++ b/tests/unit/highlight-token-alignment.vitest.spec.ts
@@ -19,6 +19,21 @@ const alignmentWords = (
}));
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);
From 379e5082f048797917202f9f642e79d061671049 Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 12:19:28 -0600
Subject: [PATCH 11/12] docs(intro): clarify open-source status and
multilingual support in introduction
Update introduction documentation and README to consistently refer to
OpenReader as "open-source" and highlight its multilingual capabilities.
Refine language segmentation example in multilingual sample text and
expand test coverage for language settings. Adjust TTS segment manifest
to normalize language tags, improve document language update logic, and
fix provider/model compatibility checks for instructions and language
input. Enhance reliability of language support detection for TTS
generation and Replicate provider integration.
---
README.md | 2 +-
docs-site/docs/introduction.md | 2 +-
src/app/api/tts/segments/manifest/route.ts | 3 ++-
src/components/admin/AdminProvidersPanel.tsx | 8 ++++----
src/components/reader/SegmentsSidebar.tsx | 4 +++-
src/hooks/useDocumentLanguage.ts | 15 +++++++++------
src/lib/server/tts/generate.ts | 2 +-
src/lib/server/tts/voice-resolution.ts | 2 +-
tests/files/multilingual-sample.txt | 2 +-
tests/unit/audiobook-settings.vitest.spec.ts | 2 ++
10 files changed, 25 insertions(+), 17 deletions(-)
diff --git a/README.md b/README.md
index f6a5b48..1e8e5c6 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
# 📄🔊 OpenReader
-OpenReader is an open source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with multilingual, synchronized read-along playback.
+OpenReader is an open-source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with multilingual, synchronized read-along playback.
> Previously named **OpenReader-WebUI**.
diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md
index a255ce3..8f60592 100644
--- a/docs-site/docs/introduction.md
+++ b/docs-site/docs/introduction.md
@@ -4,7 +4,7 @@ title: Introduction
slug: /
---
-OpenReader is an open source text-to-speech document reader built with Next.js. It provides a multilingual read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**.
+OpenReader is an open-source text-to-speech document reader built with Next.js. It provides a multilingual read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**.
> Previously named **OpenReader-WebUI**.
diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts
index 686555c..f33155d 100644
--- a/src/app/api/tts/segments/manifest/route.ts
+++ b/src/app/api/tts/segments/manifest/route.ts
@@ -18,6 +18,7 @@ import type {
TTSSegmentsManifestResponse,
} from '@/types/client';
import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
+import { normalizeLanguageTag } from '@/lib/shared/language';
import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
import { createRequestLogger } from '@/lib/server/logger';
@@ -71,7 +72,7 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
const instructionsSource = rec.ttsInstructions ?? rec.instructions;
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : '';
- const language = typeof rec.language === 'string' ? rec.language : 'en';
+ const language = typeof rec.language === 'string' ? normalizeLanguageTag(rec.language, 'en') : 'en';
if (!providerRef || !providerType || !ttsModel || !voice) return null;
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions, language };
diff --git a/src/components/admin/AdminProvidersPanel.tsx b/src/components/admin/AdminProvidersPanel.tsx
index ef92a1d..4a2f202 100644
--- a/src/components/admin/AdminProvidersPanel.tsx
+++ b/src/components/admin/AdminProvidersPanel.tsx
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
-import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
+import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import {
Badge,
@@ -327,9 +327,9 @@ export function AdminProvidersPanel() {
? 'custom'
: modelDefinitions[0]?.id ?? '';
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
- const modelSupportsInstructions = useCallback((model: string) => resolveTtsProviderModelPolicy({
+ const modelSupportsInstructions = useCallback((model: string, providerType: TtsProviderType = form.providerType) => resolveTtsProviderModelPolicy({
providerRef: form.slug,
- providerType: form.providerType,
+ providerType,
model,
}).supportsInstructions, [form.slug, form.providerType]);
const baseUrlPlaceholder = form.providerType === 'custom-openai'
@@ -425,7 +425,7 @@ export function AdminProvidersPanel() {
providerType: opt.value,
baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '',
defaultModel: nextModel,
- defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
+ defaultInstructions: modelSupportsInstructions(nextModel, opt.value) ? form.defaultInstructions : '',
});
setCustomModelInput('');
}}
diff --git a/src/components/reader/SegmentsSidebar.tsx b/src/components/reader/SegmentsSidebar.tsx
index 835fe98..33d2434 100644
--- a/src/components/reader/SegmentsSidebar.tsx
+++ b/src/components/reader/SegmentsSidebar.tsx
@@ -188,6 +188,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
activeReaderType,
clearSegmentCaches,
resolvedLanguage,
+ setDocumentLanguage,
} = useTTS();
const {
providerRef,
@@ -421,7 +422,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
updateConfigKey('voiceSpeed', Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1),
updateConfigKey('ttsInstructions', settings.ttsInstructions || ''),
]);
- }, [updateConfigKey]);
+ if (settings.language) setDocumentLanguage(settings.language);
+ }, [updateConfigKey, setDocumentLanguage]);
const handleRefresh = useCallback(() => {
didAutoScrollOnOpenRef.current = false;
diff --git a/src/hooks/useDocumentLanguage.ts b/src/hooks/useDocumentLanguage.ts
index f8474ab..c8ca980 100644
--- a/src/hooks/useDocumentLanguage.ts
+++ b/src/hooks/useDocumentLanguage.ts
@@ -31,19 +31,22 @@ export function useDocumentLanguage(documentId: string | undefined): {
const updateLanguage = useCallback(async (language: string): Promise => {
if (!documentId) return;
- const next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
- ...settings,
- schemaVersion: 1,
- language,
+ let next = DEFAULT_DOCUMENT_SETTINGS;
+ setSettings((prev) => {
+ next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
+ ...prev,
+ schemaVersion: 1,
+ language,
+ });
+ return next;
});
- setSettings(next);
try {
const response = await putDocumentSettings(documentId, next);
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
} catch (error) {
console.warn('Failed to persist document language:', error);
}
- }, [documentId, settings]);
+ }, [documentId]);
return {
language: settings.language ?? 'auto',
diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts
index c235e88..cfca3d3 100644
--- a/src/lib/server/tts/generate.ts
+++ b/src/lib/server/tts/generate.ts
@@ -644,7 +644,7 @@ async function runProviderRequest(
}
if (request.provider !== 'openai' && request.language) {
- const supportKey = `${request.baseUrl || ''}|${request.model as string}`;
+ const supportKey = `${request.provider}|${request.baseUrl || ''}|${request.model as string}|${request.language}`;
if (!openAiCompatibleLanguageUnsupported.has(supportKey)) {
try {
return await fetchTTSBufferWithRetry(
diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts
index 1fd9420..7d80a13 100644
--- a/src/lib/server/tts/voice-resolution.ts
+++ b/src/lib/server/tts/voice-resolution.ts
@@ -311,7 +311,7 @@ export async function resolveReplicateLanguageInput({
model,
apiKey = '',
}: ResolveVoicesOptions): Promise {
- if (provider !== 'replicate' || !apiKey) return null;
+ if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) return null;
const cached = replicateLanguageInputCache.get(model);
if (cached) return cached;
diff --git a/tests/files/multilingual-sample.txt b/tests/files/multilingual-sample.txt
index fb517aa..5f9436c 100644
--- a/tests/files/multilingual-sample.txt
+++ b/tests/files/multilingual-sample.txt
@@ -2,7 +2,7 @@ English
OpenReader should split this sentence correctly. This is the second sentence.
French
-OpenReader doit correctement segmenter cette phrase. Voici la deuxieme phrase.
+OpenReader doit correctement segmenter cette phrase. Voici la deuxième phrase.
Spanish
OpenReader debe segmentar correctamente esta frase. Esta es la segunda frase.
diff --git a/tests/unit/audiobook-settings.vitest.spec.ts b/tests/unit/audiobook-settings.vitest.spec.ts
index 9cd68fe..4417d01 100644
--- a/tests/unit/audiobook-settings.vitest.spec.ts
+++ b/tests/unit/audiobook-settings.vitest.spec.ts
@@ -95,6 +95,7 @@ describe('coerceAudiobookGenerationSettings', () => {
postSpeed: 1,
format: 'mp3',
ttsInstructions: '',
+ language: 'en',
},
restrictUserApiKeys: true,
fallbackProviderRef: 'shared-openai',
@@ -113,5 +114,6 @@ describe('coerceAudiobookGenerationSettings', () => {
expect(settings.providerType).toBe('openai');
expect(settings.ttsModel).toBe('gpt-4o-mini-tts');
expect(settings.ttsInstructions).toBe('Speak with warmth.');
+ expect(settings.language).toBe('en');
});
});
From f7c93c90a6fd59c565916690871bf7a5086dc64c Mon Sep 17 00:00:00 2001
From: Richard R
Date: Sat, 6 Jun 2026 13:07:51 -0600
Subject: [PATCH 12/12] fix(player): prevent selection reset during voice
refetch by deriving voices from props
Remove internal selectedVoices state and use a memoized value based on the voice
prop to determine the selected voices. Avoid side effects on selection display,
ensuring that voice selection persists even when available voices are refetched.
This addresses issues where transient backend states could cause unwanted resets
to the first voice option.
---
src/components/player/VoicesControlBase.tsx | 25 ++++++++++-----------
1 file changed, 12 insertions(+), 13 deletions(-)
diff --git a/src/components/player/VoicesControlBase.tsx b/src/components/player/VoicesControlBase.tsx
index b314902..f2e4a3e 100644
--- a/src/components/player/VoicesControlBase.tsx
+++ b/src/components/player/VoicesControlBase.tsx
@@ -4,7 +4,7 @@ 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';
@@ -56,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);
@@ -71,13 +76,8 @@ export function VoicesControlBase({
if (initial.length > maxVoices) {
initial = initial.slice(0, maxVoices);
}
- initial = keepKokoroVoicesInOneLanguage(initial);
- setSelectedVoices(initial);
- const combined = buildKokoroVoiceString(initial);
- if (combined && combined !== voice) {
- onChangeVoice(combined);
- }
- }, [isKokoro, maxVoices, voice, availableVoices, onChangeVoice]);
+ return keepKokoroVoicesInOneLanguage(initial);
+ }, [isKokoro, maxVoices, voice, availableVoices]);
const currentVoice = useMemo(() => {
if (isKokoro && maxVoices > 1) {
@@ -123,7 +123,6 @@ export function VoicesControlBase({
}
}
- setSelectedVoices(next);
const combined = buildKokoroVoiceString(next);
if (combined) {
onChangeVoice(combined);