Merge pull request #102 from feat/multilingual-tier3-support

Add multilingual reader and TTS support

- Detect, normalize, persist, and expose document language across EPUB, PDF, HTML, TTS segment, and audiobook workflows.
- Forward supported language inputs to TTS providers, warn on voice/document language mismatches, and prevent incompatible Kokoro voice selections.
- Centralize Unicode-aware token alignment so EPUB, HTML, and PDF highlighting handles multilingual text, including Japanese matching.
- Standardize shared select, checkbox, button, input, menu, and popover primitives across reader, settings, admin, authentication, and public UI surfaces.
- Update README, introduction docs, and landing-page copy to describe multilingual narration and highlighting.
This commit is contained in:
Richard R 2026-06-06 13:58:11 -06:00 committed by GitHub
commit 3d1ef1fd41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 2101 additions and 1191 deletions

View file

@ -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.

View file

@ -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

View file

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

View file

@ -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<void>;
clearCurrDoc: () => void;
@ -93,7 +95,16 @@ export interface EpubDocumentState {
* Route-local EPUB reader hook.
*/
export function useEpubDocument(documentId?: string): EpubDocumentState {
const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS();
const {
setText: setTTSText,
currDocPage,
currDocPages,
setCurrDocPages,
stop,
skipToLocation,
setIsEPUB,
resolvedLanguage,
} = useTTS();
// Configuration context to get TTS settings
const {
apiKey,
@ -107,6 +118,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
const [currDocName, setCurrDocName] = useState<string>();
const [currDocText, setCurrDocText] = useState<string>();
const [metadataLanguage, setMetadataLanguage] = useState<string | null>(null);
const [isPlaybackReady, setIsPlaybackReady] = useState(false);
// Mirror state into a ref so resolveEpubLocator (registered once with
// TTSContext via a stable callback) can always read the latest page text
@ -145,6 +157,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
currentWordHighlightCfiRef: currentWordHighlightCfi,
renderedTextMapsRef,
wordHighlightMapCacheRef,
language: resolvedLanguage,
});
useEffect(() => () => {
@ -162,6 +175,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
setCurrDocData(undefined);
setCurrDocName(undefined);
setCurrDocText(undefined);
setMetadataLanguage(null);
setIsPlaybackReady(false);
setCurrDocPages(undefined);
isEPUBSetOnce.current = false;
@ -183,6 +197,9 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
try {
setIsPlaybackReady(false);
setMetadataLanguage(null);
bookRef.current = null;
renditionRef.current = undefined;
const meta = await getDocumentMetadata(id);
if (!meta) {
clearCurrDoc();
@ -370,8 +387,19 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
});
const setRendition = useCallback((rendition: Rendition) => {
bookRef.current = rendition.book;
const book = rendition.book;
bookRef.current = book;
renditionRef.current = rendition;
void book.loaded.metadata
.then((metadata) => {
if (bookRef.current !== book) return;
setMetadataLanguage(normalizeOptionalLanguageTag(metadata.language));
})
.catch((error) => {
if (bookRef.current !== book) return;
setMetadataLanguage(null);
console.warn('Failed to read EPUB language metadata:', error);
});
}, []);
const handleLocationChanged = useEPUBLocationController({
@ -396,6 +424,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
currDocPages,
currDocPage,
currDocText,
metadataLanguage,
isPlaybackReady,
clearCurrDoc,
extractPageText,
@ -422,6 +451,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
currDocPages,
currDocPage,
currDocText,
metadataLanguage,
isPlaybackReady,
clearCurrDoc,
extractPageText,

View file

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

View file

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

View file

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

View file

@ -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 */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
<Checkbox
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="rounded border-muted text-accent focus:ring-accent-line"
/>
<span className="text-sm text-foreground">Remember me</span>
</label>
@ -238,12 +236,9 @@ function SignInContent() {
)}
<p className="text-xs text-soft">
By signing in, you agree to our{' '}
<button
onClick={() => showPrivacyModal()}
className="underline hover:text-foreground"
>
<InlineButton onClick={() => showPrivacyModal()}>
Privacy Policy
</button>
</InlineButton>
</p>
</div>
</Surface>

View file

@ -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() {
</p>
<p className="text-xs text-soft">
By creating an account, you agree to our{' '}
<button
onClick={() => showPrivacyModal()}
className="underline hover:text-foreground"
>
<InlineButton onClick={() => showPrivacyModal()}>
Privacy Policy
</button>
</InlineButton>
</p>
</div>
</Surface>

View file

@ -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() {
<p className="public-hero-copy">
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&rsquo;s open source, and
entirely yours to self-host.
</p>
@ -126,7 +126,7 @@ export default async function LandingPage() {
<span className="public-reader-dot" data-tone="b" />
<span className="public-reader-dot" data-tone="c" />
<span className="public-reader-file">wizard-of-oz.epub</span>
<span className="public-reader-voice">Kokoro · af_sky</span>
<span className="public-reader-voice">English · Kokoro · af_sky</span>
</div>
<div className="public-reader-body">
@ -203,7 +203,8 @@ export default async function LandingPage() {
<p>
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.
</p>
</li>
<li className="public-step">
@ -251,11 +252,11 @@ export default async function LandingPage() {
</article>
<article className="public-feature">
<span className="public-feature-kicker">Voices</span>
<h3>Multi-provider TTS</h3>
<span className="public-feature-kicker">Languages</span>
<h3>Multilingual support</h3>
<p>
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.
</p>
</article>

View file

@ -432,7 +432,8 @@ export async function POST(request: NextRequest) {
normalizedExistingSettings.nativeSpeed !== normalizedIncomingSettings.nativeSpeed ||
normalizedExistingSettings.postSpeed !== normalizedIncomingSettings.postSpeed ||
normalizedExistingSettings.format !== normalizedIncomingSettings.format ||
(normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || '');
(normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || '') ||
(normalizedExistingSettings.language || '') !== (normalizedIncomingSettings.language || '');
if (mismatch) {
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: normalizedExistingSettings }, { status: 409 });
}
@ -596,6 +597,7 @@ export async function POST(request: NextRequest) {
format: 'mp3',
model,
instructions,
language: mergedSettings?.language,
provider,
apiKey: openApiKey,
baseUrl: openApiBaseUrl,

View file

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

View file

@ -18,6 +18,7 @@ import type {
TTSSegmentsManifestResponse,
} from '@/types/client';
import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { normalizeLanguageTag } from '@/lib/shared/language';
import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
import { createRequestLogger } from '@/lib/server/logger';
@ -71,9 +72,10 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
const instructionsSource = rec.ttsInstructions ?? rec.instructions;
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : '';
const language = typeof rec.language === 'string' ? normalizeLanguageTag(rec.language, 'en') : 'en';
if (!providerRef || !providerType || !ttsModel || !voice) return null;
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions };
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions, language };
}
function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {

View file

@ -1,19 +1,19 @@
'use client';
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { Transition, Listbox, Menu, MenuButton } from '@headlessui/react';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
import { ProgressPopup } from '@/components/ProgressPopup';
import { ProgressCard } from '@/components/ProgressCard';
import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons';
import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { useConfig } from '@/contexts/ConfigContext';
import { useTTS } from '@/contexts/TTSContext';
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { getTtsLanguageCompatibilityWarnings, resolveTtsLanguage } from '@/lib/shared/language';
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui';
import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, RangeInput, Select } from '@/components/ui';
import {
getAudiobookStatus,
deleteAudiobookChapter,
@ -50,7 +50,7 @@ export function AudiobookExportModal({
onRegenerateChapter
}: AudiobookExportModalProps) {
const { isLoading, isDBReady, providerRef, providerType, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig();
const { availableVoices } = useTTS();
const { availableVoices, documentLanguage } = useTTS();
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const [isGenerating, setIsGenerating] = useState(false);
const [chapters, setChapters] = useState<TTSAudiobookChapter[]>([]);
@ -125,8 +125,17 @@ export function AudiobookExportModal({
postSpeed,
format,
ttsInstructions: providerModelPolicy.supportsInstructions ? ttsInstructions : undefined,
language: resolveTtsLanguage({
configuredLanguage: documentLanguage,
voice: nextVoice,
}),
};
}, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions]);
}, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions, documentLanguage]);
const languageWarnings = useMemo(() => getTtsLanguageCompatibilityWarnings({
model: effectiveSettings?.ttsModel,
voice: effectiveSettings?.voice,
documentLanguage: effectiveSettings?.language,
}), [effectiveSettings]);
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
if (soft) {
@ -555,56 +564,35 @@ export function AudiobookExportModal({
<div className="space-y-1.5">
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Format</label>
{chapters.length === 0 ? (
<Listbox
<Select
value={format}
onChange={(newFormat) => setFormat(newFormat)}
options={['m4b', 'mp3'] as const}
disabled={chapters.length > 0 || settingsLocked}
>
<div className="relative">
<SharedListboxButton className="bg-surface">
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-soft" />
</span>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-standard duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<SharedListboxOptions className="absolute left-0 mt-1 w-full">
<SharedListboxOption
value="m4b"
inset="none"
itemClassName="py-2"
>
{({ selected }) => (
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
M4B
</span>
)}
</SharedListboxOption>
<SharedListboxOption
value="mp3"
inset="none"
itemClassName="py-2"
>
{({ selected }) => (
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
MP3
</span>
)}
</SharedListboxOption>
</SharedListboxOptions>
</Transition>
</div>
</Listbox>
renderValue={(option) => (
<span className="text-sm font-medium">{option.toUpperCase()}</span>
)}
renderOption={(option, { selected }) => (
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
{option.toUpperCase()}
</span>
)}
buttonClassName="bg-surface"
chevronClassName="h-4 w-4 text-soft"
optionInset="none"
optionItemClassName="py-2"
showCheckmark={false}
/>
) : (
<div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div>
)}
</div>
</div>
{languageWarnings.map((warning) => (
<p key={warning} className="text-xs text-warning">
{warning}
</p>
))}
{/* Speed controls */}
<Card className="p-3 space-y-3">
@ -773,23 +761,15 @@ export function AudiobookExportModal({
</div>
<div className="flex items-center">
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
<Menu as="div" className="relative inline-block text-left">
<MenuButton
<MenuRoot as="div" className="relative inline-block text-left">
<MenuTrigger
as={IconButton}
size="sm"
title="Chapter actions"
>
<DotsVerticalIcon className="h-5 w-5" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-standard duration-fast"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-standard duration-fast"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
</MenuTrigger>
<MenuTransition>
<MenuItemsSurface
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
portal
@ -833,8 +813,8 @@ export function AudiobookExportModal({
)}
</MenuItemsSurface>
{/* end of menu items */}
</Transition>
</Menu>
</MenuTransition>
</MenuRoot>
)}
</div>
</div>

View file

@ -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 (
<Popover className="relative flex items-center">
<PopoverButton as={IconButton} size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
<PopoverRoot className="relative flex items-center">
<PopoverIconTrigger size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
<div
className="w-6 h-6 rounded-full border-2 transition duration-fast group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
style={{
@ -87,7 +86,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
borderColor: isLightColor(value) ? '#00000022' : '#ffffff22',
}}
/>
</PopoverButton>
</PopoverIconTrigger>
<PopoverSurface
anchor="bottom start"
@ -171,6 +170,6 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
/>
</div>
</PopoverSurface>
</Popover>
</PopoverRoot>
);
}

View file

@ -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)
<div className="mt-6 space-y-4">
<div className="flex items-start gap-3 rounded-lg border border-line p-3 bg-surface-sunken">
<div className="flex h-6 items-center">
<input
<Checkbox
data-testid="privacy-agree-checkbox"
id="privacy-agree"
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface"
/>
</div>
<div className="text-sm leading-6">

View file

@ -1,14 +1,10 @@
'use client';
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
import {
Transition,
Listbox,
} from '@headlessui/react';
import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
import { ChevronUpDownIcon, CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons';
import { CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { ProgressPopup } from '@/components/ProgressPopup';
@ -50,12 +46,10 @@ import {
ChoiceTile,
IconButton,
Input,
Textarea,
ModalFrame,
ModalTitle,
inputClass,
SharedListboxButton,
SharedListboxOption,
SharedListboxOptions,
Select,
} from '@/components/ui';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@ -654,8 +648,16 @@ export function SettingsModal({
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
</p>
) : (
<Listbox
<Select
value={selectedProviderOption!}
options={ttsProviders}
getOptionKey={(provider) => provider.id}
renderValue={(provider) => provider.name}
renderOption={(provider, { selected }) => (
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{provider.name}
</span>
)}
onChange={(provider) => {
const defaults = resolveProviderDefaults({
providerRef: provider.id,
@ -675,46 +677,7 @@ export function SettingsModal({
}
setCustomModelInput('');
}}
>
<SharedListboxButton>
<span className="block truncate">
{selectedProviderOption?.name || 'Select Provider'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
</span>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-standard duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<SharedListboxOptions
anchor="bottom start"
>
{ttsProviders.map((provider) => (
<SharedListboxOption
key={provider.id}
value={provider}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{provider.name}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</SharedListboxOption>
))}
</SharedListboxOptions>
</Transition>
</Listbox>
/>
)}
</div>
{restrictUserApiKeys && (
@ -734,7 +697,6 @@ export function SettingsModal({
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
className={inputClass}
/>
</div>
)}
@ -750,7 +712,6 @@ export function SettingsModal({
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder="Using environment variable"
className={inputClass}
/>
</div>
)}
@ -768,8 +729,30 @@ export function SettingsModal({
</p>
)}
<div className="flex flex-col gap-2">
<Listbox
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
<Select
value={selectedModel}
options={ttsModels}
getOptionKey={(model) => model.id}
renderValue={(model) => (
<span className="block">
<span className="block truncate">{model.name}</span>
{selectedModelVersion ? (
<span className="block truncate text-xs text-soft">
{selectedModelVersion}
</span>
) : null}
</span>
)}
renderOption={(model, { selected }) => (
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
<span className="block truncate">{model.name}</span>
{model.id.includes(':') ? (
<span className="block truncate text-xs text-soft">
{model.id.slice(model.id.indexOf(':'))}
</span>
) : null}
</span>
)}
onChange={(model) => {
if (model.id === 'custom') {
setModelValue(customModelInput);
@ -778,62 +761,7 @@ export function SettingsModal({
setCustomModelInput('');
}
}}
>
<SharedListboxButton>
{selectedModel ? (
<span className="block">
<span className="block truncate">
{selectedModel.name}
</span>
{selectedModelVersion && (
<span className="block truncate text-xs text-soft">
{selectedModelVersion}
</span>
)}
</span>
) : (
<span className="block truncate">Select Model</span>
)}
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-soft" />
</span>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-standard duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<SharedListboxOptions
anchor="bottom start"
>
{ttsModels.map((model) => (
<SharedListboxOption
key={model.id}
value={model}
>
{({ selected }) => (
<>
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
<span className="block truncate">{model.name}</span>
{model.id.includes(':') && (
<span className="block truncate text-xs text-soft">
{model.id.slice(model.id.indexOf(':'))}
</span>
)}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</SharedListboxOption>
))}
</SharedListboxOptions>
</Transition>
</Listbox>
/>
{supportsCustom && selectedModelId === 'custom' && (
<Input
@ -844,7 +772,6 @@ export function SettingsModal({
setModelValue(e.target.value);
}}
placeholder="Enter custom model name"
className={inputClass}
/>
)}
</div>
@ -853,11 +780,11 @@ export function SettingsModal({
{providerModelPolicy.supportsInstructions && (
<div className="space-y-1.5">
<label className={fieldLabelClass}>TTS Instructions</label>
<textarea
<Textarea
value={localTTSInstructions}
onChange={(e) => setLocalTTSInstructions(e.target.value)}
placeholder="Enter instructions for the TTS model"
className={`${inputClass} h-24 resize-none`}
className="h-24 resize-none"
/>
</div>
)}

View file

@ -1,19 +1,15 @@
'use client';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Listbox, Transition } from '@headlessui/react';
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import {
Badge,
Section,
ToggleRow,
inputClass,
SharedListboxButton,
SharedListboxOption,
SharedListboxOptions,
Select,
Button,
Input,
} from '@/components/ui';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
@ -204,42 +200,19 @@ export function AdminFeaturesPanel() {
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
</div>
{providerOptions.length > 0 ? (
<Listbox value={selectedProviderOption} onChange={handleProviderChange}>
<SharedListboxButton>
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
<Select
value={selectedProviderOption}
onChange={handleProviderChange}
options={providerOptions}
getOptionKey={(option) => option.id}
renderValue={(option) => option.name}
renderOption={(option, { selected }) => (
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.name}
</span>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<SharedListboxOptions anchor="bottom start">
{providerOptions.map((opt) => (
<SharedListboxOption
key={opt.id}
value={opt}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{opt.name}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</SharedListboxOption>
))}
</SharedListboxOptions>
</Transition>
</Listbox>
)}
chevronClassName="h-4 w-4 text-muted"
/>
) : (
<div className="px-0.5 py-2 text-sm text-muted">
No shared providers yet. Add one first.
@ -301,11 +274,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Anonymous per-user daily limit</label>
{renderSource('ttsDailyLimitAnonymous')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsDailyLimitAnonymous ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
/>
@ -315,11 +287,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Authenticated per-user daily limit</label>
{renderSource('ttsDailyLimitAuthenticated')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
/>
@ -329,11 +300,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Anonymous IP daily backstop</label>
{renderSource('ttsIpDailyLimitAnonymous')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
/>
@ -343,11 +313,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Authenticated IP daily backstop</label>
{renderSource('ttsIpDailyLimitAuthenticated')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
/>
@ -370,11 +339,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Burst limit (parses)</label>
{renderSource('computeParseBurstMax')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseBurstMax ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseBurstMax', event.target.value)}
/>
@ -384,11 +352,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Burst window (seconds)</label>
{renderSource('computeParseBurstWindowSec')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseBurstWindowSec ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)}
/>
@ -398,11 +365,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Sustained limit (parses)</label>
{renderSource('computeParseSustainedMax')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseSustainedMax ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseSustainedMax', event.target.value)}
/>
@ -412,11 +378,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Sustained window (seconds)</label>
{renderSource('computeParseSustainedWindowSec')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseSustainedWindowSec ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
/>
@ -432,13 +397,13 @@ export function AdminFeaturesPanel() {
</div>
<div className="shrink-0 self-start pl-1.5">{renderSource('maxUploadMb')}</div>
<div className="shrink-0 flex items-center gap-1.5">
<input
<Input
type="number"
min={1}
step={1}
inputMode="numeric"
aria-label="Max upload size in megabytes"
className="w-20 rounded-md bg-background border border-offbase px-2.5 py-1.5 text-sm text-foreground text-right focus:outline-none focus:ring-2 focus:ring-accent"
className="w-20 text-right"
value={String(draft.maxUploadMb ?? '')}
onChange={(event) => updatePositiveIntDraft('maxUploadMb', event.target.value)}
/>
@ -463,9 +428,8 @@ export function AdminFeaturesPanel() {
</div>
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
</div>
<input
<Input
type="text"
className={inputClass}
value={String(draft.changelogFeedUrl ?? '')}
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
@ -508,11 +472,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Retry attempts</label>
{renderSource('ttsUpstreamMaxRetries')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsUpstreamMaxRetries ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)}
/>
@ -522,11 +485,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Upstream timeout (ms)</label>
{renderSource('ttsUpstreamTimeoutMs')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsUpstreamTimeoutMs ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)}
/>
@ -536,11 +498,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Audio cache size (bytes)</label>
{renderSource('ttsCacheMaxSizeBytes')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsCacheMaxSizeBytes ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)}
/>
@ -550,11 +511,10 @@ export function AdminFeaturesPanel() {
<label className="text-xs font-medium text-foreground">Audio cache TTL (ms)</label>
{renderSource('ttsCacheTtlMs')}
</div>
<input
<Input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsCacheTtlMs ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)}
/>

View file

@ -1,27 +1,26 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Listbox, Menu, MenuButton, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon, DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import {
Badge,
Field,
Section,
ToggleRow,
inputClass,
SharedListboxButton,
SharedListboxOption,
SharedListboxOptions,
Select,
Button,
IconButton,
Input,
Textarea,
MenuItemsSurface,
MenuActionItem,
MenuRoot,
MenuTrigger,
MenuTransition,
} from '@/components/ui';
type ProviderType = TtsProviderId;
@ -328,9 +327,9 @@ export function AdminProvidersPanel() {
? 'custom'
: modelDefinitions[0]?.id ?? '';
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
const modelSupportsInstructions = useCallback((model: string) => resolveTtsProviderModelPolicy({
const modelSupportsInstructions = useCallback((model: string, providerType: TtsProviderType = form.providerType) => resolveTtsProviderModelPolicy({
providerRef: form.slug,
providerType: form.providerType,
providerType,
model,
}).supportsInstructions, [form.slug, form.providerType]);
const baseUrlPlaceholder = form.providerType === 'custom-openai'
@ -396,7 +395,6 @@ export function AdminProvidersPanel() {
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
placeholder="kokoro-prod"
className={inputClass}
disabled={isEditingExisting}
/>
</Field>
@ -406,12 +404,20 @@ export function AdminProvidersPanel() {
value={form.displayName}
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
placeholder="Kokoro (production)"
className={inputClass}
/>
</Field>
<Field label="Provider type">
<Listbox
<Select
value={selectedProviderType}
options={PROVIDER_TYPE_OPTIONS}
getOptionKey={(option) => option.value}
renderValue={(option) => option.label}
renderOption={(option, { selected }) => (
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{option.label}
</span>
)}
chevronClassName="h-4 w-4 text-muted"
onChange={(opt) => {
const nextModel = providerDefaultModel(opt.value);
setForm({
@ -419,53 +425,33 @@ export function AdminProvidersPanel() {
providerType: opt.value,
baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '',
defaultModel: nextModel,
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
defaultInstructions: modelSupportsInstructions(nextModel, opt.value) ? form.defaultInstructions : '',
});
setCustomModelInput('');
}}
>
<SharedListboxButton>
<span className="block truncate">{selectedProviderType.label}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<SharedListboxOptions anchor="bottom start">
{PROVIDER_TYPE_OPTIONS.map((opt) => (
<SharedListboxOption
key={opt.value}
value={opt}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{opt.label}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</SharedListboxOption>
))}
</SharedListboxOptions>
</Transition>
</Listbox>
/>
</Field>
<Field label="Default model" hint="Pre-selected for users picking this provider.">
<div className="space-y-2">
<Listbox
value={selectedModelId}
onChange={(modelId: string) => {
if (modelId === 'custom') {
<Select
value={selectedModelDefinition}
options={modelDefinitions}
getOptionKey={(model) => model.id}
renderValue={(model) => model.name}
renderOption={(model, { selected }) => (
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
<span className="block truncate">{model.name}</span>
{model.id.includes(':') ? (
<span className="block truncate text-xs text-muted">
{model.id.slice(model.id.indexOf(':'))}
</span>
) : null}
</span>
)}
placeholder="Select model"
chevronClassName="h-4 w-4 text-muted"
onChange={(model) => {
if (model.id === 'custom') {
const nextModel = customModelInput.trim();
setForm({
...form,
@ -476,54 +462,12 @@ export function AdminProvidersPanel() {
}
setForm({
...form,
defaultModel: modelId,
defaultInstructions: modelSupportsInstructions(modelId) ? form.defaultInstructions : '',
defaultModel: model.id,
defaultInstructions: modelSupportsInstructions(model.id) ? form.defaultInstructions : '',
});
setCustomModelInput('');
}}
>
<SharedListboxButton>
<span className="block truncate">
{selectedModelDefinition?.name ?? 'Select model'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</SharedListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<SharedListboxOptions anchor="bottom start">
{modelDefinitions.map((model) => (
<SharedListboxOption
key={model.id}
value={model.id}
>
{({ selected }) => (
<>
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
<span className="block truncate">{model.name}</span>
{model.id.includes(':') && (
<span className="block truncate text-xs text-muted">
{model.id.slice(model.id.indexOf(':'))}
</span>
)}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</SharedListboxOption>
))}
</SharedListboxOptions>
</Transition>
</Listbox>
/>
{supportsCustomModel && selectedModelId === 'custom' && (
<Input
type="text"
@ -538,7 +482,6 @@ export function AdminProvidersPanel() {
});
}}
placeholder="Enter custom model id"
className={inputClass}
/>
)}
</div>
@ -549,11 +492,11 @@ export function AdminProvidersPanel() {
className="sm:col-span-2"
hint="Optional. Applied by default when this shared provider is selected."
>
<textarea
<Textarea
value={form.defaultInstructions}
onChange={(e) => setForm({ ...form, defaultInstructions: e.target.value })}
placeholder="Enter instructions for this model"
className={`${inputClass} min-h-24 resize-y`}
className="min-h-24 resize-y"
/>
</Field>
)}
@ -564,7 +507,6 @@ export function AdminProvidersPanel() {
value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
placeholder={baseUrlPlaceholder}
className={inputClass}
/>
</Field>
)}
@ -578,7 +520,6 @@ export function AdminProvidersPanel() {
value={form.apiKey}
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
placeholder={isEditingExisting ? `keep existing (${editingProvider?.apiKeyMask ?? ''})` : 'Optional'}
className={inputClass}
/>
</Field>
</div>
@ -639,8 +580,8 @@ export function AdminProvidersPanel() {
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
</div>
</div>
<Menu as="div" className="relative shrink-0">
<MenuButton
<MenuRoot as="div" className="relative shrink-0">
<MenuTrigger
as={IconButton}
tone="surface"
size="sm"
@ -649,16 +590,8 @@ export function AdminProvidersPanel() {
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
>
<DotsHorizontalIcon className="h-3 w-4" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
</MenuTrigger>
<MenuTransition>
<MenuItemsSurface
anchor="bottom end"
className="z-50 mt-2 min-w-[170px] bg-base focus:outline-none"
@ -679,8 +612,8 @@ export function AdminProvidersPanel() {
Delete
</MenuActionItem>
</MenuItemsSurface>
</Transition>
</Menu>
</MenuTransition>
</MenuRoot>
</div>
</div>
))

View file

@ -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({
<SidebarNavGroup
action={(
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuButton
<MenuRoot as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuTrigger
as={IconButton}
size="xs"
className="h-3.5 w-5"
@ -210,16 +209,8 @@ export function FinderSidebar({
aria-label="Folder actions"
>
<DotsHorizontalIcon className="w-4 h-2.5" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-standard duration-fast"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-standard duration-fast"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
</MenuTrigger>
<MenuTransition>
<MenuItemsSurface
anchor="bottom start"
className="z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal"
@ -246,8 +237,8 @@ export function FinderSidebar({
Remove All Folders
</MenuActionItem>
</MenuItemsSurface>
</Transition>
</Menu>
</MenuTransition>
</MenuRoot>
)}
>
Folders

View file

@ -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 = (
<div className="sm:hidden flex items-center">
<Menu as="div" className="relative inline-block text-left">
<MenuButton
<MenuRoot as="div" className="relative inline-block text-left">
<MenuTrigger
as={IconButton}
tone="surface"
size="sm"
title="Menu"
>
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-standard duration-fast"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-standard duration-fast"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
</MenuTrigger>
<MenuTransition>
<MenuItemsSurface className="absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none">
{/* Zoom Controls Section */}
<div className="px-4 py-3">
@ -140,8 +130,8 @@ export function DocumentHeaderMenu({
<UserMenu />
</div>
</MenuItemsSurface>
</Transition>
</Menu>
</MenuTransition>
</MenuRoot>
</div>
);

View file

@ -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 && (
<div className="flex items-center text-sm font-normal">
<label className="flex items-center gap-2 cursor-pointer select-none text-soft hover:text-foreground transition-colors">
<input
type="checkbox"
className="rounded border-muted text-accent focus:ring-accent-line"
<Checkbox
checked={allSelected}
ref={input => {
if (input) input.indeterminate = isIndeterminate;
@ -154,11 +152,9 @@ export function DocumentSelectionModal({
}`}
>
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
<Checkbox
checked={isSelected}
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
className="rounded border-muted text-accent focus:ring-accent-line"
/>
</div>
<div

View file

@ -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,
@ -15,10 +16,19 @@ import {
clampSegmentPreloadSentenceLookahead,
clampTtsSegmentMaxBlockLength,
} from '@/types/config';
import { IconButton, RangeInput, Section, ToggleRow, CheckItem, SegmentedControl } from '@/components/ui';
import {
IconButton,
RangeInput,
Section,
ToggleRow,
CheckItem,
SegmentedControl,
Select,
} from '@/components/ui';
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' },
@ -50,6 +60,20 @@ const viewTypeTextMapping = [
{ id: 'scroll', name: 'Continuous Scroll' },
];
const DOCUMENT_LANGUAGE_OPTIONS = [
{ value: 'auto', label: 'Automatic (voice or metadata)' },
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'fr', label: 'French' },
{ value: 'hi', label: 'Hindi' },
{ value: 'it', label: 'Italian' },
{ value: 'ja', label: 'Japanese' },
{ value: 'pt-BR', label: 'Portuguese (Brazil)' },
{ value: 'zh-CN', label: 'Chinese (Simplified)' },
{ value: 'ar', label: 'Arabic' },
{ value: 'th', label: 'Thai' },
];
type RangeSettingProps = {
label: string;
value: number;
@ -92,11 +116,14 @@ function RangeSetting({
);
}
export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, detectedLanguage, onLanguageChange, pdf }: {
isOpen: boolean,
setIsOpen: (isOpen: boolean) => void,
epub?: boolean,
html?: boolean,
language?: string,
detectedLanguage?: string | null,
onLanguageChange?: (language: string) => void,
pdf?: {
parseStatus: PdfParseStatus | null;
parsedOverlayEnabled: boolean;
@ -121,7 +148,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
epubWordHighlightEnabled,
htmlHighlightEnabled,
htmlWordHighlightEnabled,
ttsModel,
} = useConfig();
const { voice, resolvedLanguage } = useTTS();
const languageWarnings = getTtsLanguageCompatibilityWarnings({
model: ttsModel,
voice,
documentLanguage: resolvedLanguage,
});
const selectedLanguage = DOCUMENT_LANGUAGE_OPTIONS.find((option) => option.value === language)
?? DOCUMENT_LANGUAGE_OPTIONS[0];
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
const isPdfMode = !epub && !html && !!pdf;
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
@ -151,6 +187,34 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
panelClassName="w-full sm:w-[30rem]"
>
<div className="space-y-4">
{language && onLanguageChange ? (
<Section
title="Language"
subtitle="Controls sentence splitting and synchronized word alignment."
variant="flat"
>
<div className="space-y-1.5">
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted">
Document language
</span>
<Select
value={selectedLanguage}
onChange={(option) => onLanguageChange(option.value)}
options={DOCUMENT_LANGUAGE_OPTIONS}
/>
</div>
{language === 'auto' && detectedLanguage ? (
<p className="text-xs text-soft">
Detected from document metadata: {getLanguageDisplayName(detectedLanguage)}
</p>
) : null}
{languageWarnings.map((warning) => (
<p key={warning} className="text-xs text-warning">
{warning}
</p>
))}
</Section>
) : null}
{isPdfMode && pdf && (
<Section
title="PDF Essentials"

View file

@ -1,8 +1,7 @@
'use client';
import { Popover } from '@headlessui/react';
import { useState, useEffect, useRef } from 'react';
import { IconButton, Input, PopoverSurface, PopoverTrigger } from '@/components/ui';
import { IconButton, Input, PopoverRoot, PopoverSurface, PopoverTrigger } from '@/components/ui';
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
currentPage: number;
@ -66,7 +65,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
</IconButton>
{/* Page number popup */}
<Popover className="relative mb-1">
<PopoverRoot className="relative mb-1">
<PopoverTrigger className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs" onClick={handlePopoverOpen}>
<p className="text-xs whitespace-nowrap">
{currentPage} / {numPages || 1}
@ -92,7 +91,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
<div className="text-xs text-soft text-center">of {numPages || 1}</div>
</div>
</PopoverSurface>
</Popover>
</PopoverRoot>
{/* Page forward */}
<IconButton

View file

@ -1,11 +1,10 @@
'use client';
import { Popover } from '@headlessui/react';
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
import { useConfig } from '@/contexts/ConfigContext';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui';
import { PopoverRoot, PopoverSurface, PopoverTrigger, RangeInput } from '@/components/ui';
export const SpeedControl = ({
setSpeedAndRestart,
@ -88,7 +87,7 @@ export const SpeedControl = ({
const step = 0.1;
return (
<Popover className="relative">
<PopoverRoot className="relative">
<PopoverTrigger className="space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm">
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
<span className="sm:hidden">{compactTriggerLabel}</span>
@ -148,6 +147,6 @@ export const SpeedControl = ({
</div>
</div>
</PopoverSurface>
</Popover>
</PopoverRoot>
);
};

View file

@ -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 (
<VoicesControlBase
availableVoices={availableVoices}
voice={voice || ''}
onChangeVoice={onChangeVoice}
providerType={providerType}
ttsModel={ttsModel}
/>
<div className="flex items-center gap-1">
<VoicesControlBase
availableVoices={availableVoices}
voice={voice || ''}
onChangeVoice={onChangeVoice}
providerType={providerType}
ttsModel={ttsModel}
/>
{languageWarnings.length > 0 ? (
<span
aria-label="Voice language warning"
title={languageWarnings.join(' ')}
className="text-warning"
>
<InfoIcon className="h-3.5 w-3.5" />
</span>
) : null}
</div>
);
}

View file

@ -4,8 +4,9 @@ import {
Listbox,
} from '@headlessui/react';
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
import { useEffect, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
import { keepKokoroVoicesInOneLanguage } from '@/lib/shared/language';
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui';
@ -55,10 +56,15 @@ export function VoicesControlBase({
const isKokoro = providerModelPolicy.isKokoroModel;
const maxVoices = providerModelPolicy.maxVoices;
const [selectedVoices, setSelectedVoices] = useState<string[]>([]);
useEffect(() => {
if (!(isKokoro && maxVoices > 1)) return;
// Selected voices are derived from the `voice` prop for display only. The
// control is fully controlled: it never writes back to config as a side
// effect, only in direct response to a user selection (see onChange below).
// A write-back effect here would re-fire during the availableVoices refetch
// churn that a voice change triggers and, under a slow/contended backend,
// push a transiently-collapsed selection back as the voice — resetting it to
// the first option.
const selectedVoices = useMemo(() => {
if (!(isKokoro && maxVoices > 1)) return [];
let initial: string[] = [];
if (voice && voice.includes('+')) {
initial = parseKokoroVoiceNames(voice);
@ -70,7 +76,7 @@ export function VoicesControlBase({
if (initial.length > maxVoices) {
initial = initial.slice(0, maxVoices);
}
setSelectedVoices(initial);
return keepKokoroVoicesInOneLanguage(initial);
}, [isKokoro, maxVoices, voice, availableVoices]);
const currentVoice = useMemo(() => {
@ -101,19 +107,22 @@ export function VoicesControlBase({
onChange={(vals: string[]) => {
if (!vals || vals.length === 0) return;
let next = vals;
if (vals.length > maxVoices) {
const newlyAdded = vals.find((v) => !selectedVoices.includes(v));
const newlyAdded = vals.find((v) => !selectedVoices.includes(v));
let next = keepKokoroVoicesInOneLanguage(vals, newlyAdded);
if (next.length > maxVoices) {
if (newlyAdded) {
const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? '';
const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean);
next = pair.slice(0, maxVoices);
const compatiblePrevious = selectedVoices.filter(
(voiceId) => voiceId !== newlyAdded && next.includes(voiceId),
);
next = [
...compatiblePrevious.slice(-(maxVoices - 1)),
newlyAdded,
];
} else {
next = vals.slice(-maxVoices);
next = next.slice(-maxVoices);
}
}
setSelectedVoices(next);
const combined = buildKokoroVoiceString(next);
if (combined) {
onChangeVoice(combined);

View file

@ -1,14 +1,14 @@
'use client';
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Popover, PopoverButton, Transition } from '@headlessui/react';
import { Transition } from '@headlessui/react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import type { Book } from 'epubjs';
import toast from 'react-hot-toast';
import { useTTS } from '@/contexts/TTSContext';
import { useConfig } from '@/contexts/ConfigContext';
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
import { Button, IconButton, PopoverSurface } from '@/components/ui';
import { Button, IconButton, PopoverIconTrigger, PopoverRoot, PopoverSurface } from '@/components/ui';
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
@ -94,6 +94,7 @@ function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings |
&& a.voice === b.voice
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
&& (a.ttsInstructions || '') === (b.ttsInstructions || '')
&& (a.language || 'en') === (b.language || 'en')
);
}
@ -186,6 +187,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
playFromSegment,
activeReaderType,
clearSegmentCaches,
resolvedLanguage,
setDocumentLanguage,
} = useTTS();
const {
providerRef,
@ -348,7 +351,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
voice,
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
ttsInstructions: ttsInstructions || '',
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions]);
language: resolvedLanguage,
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions, resolvedLanguage]);
const handleClearCache = useCallback(async () => {
if (!documentId || isClearingSegments) return;
@ -418,7 +422,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
updateConfigKey('voiceSpeed', Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1),
updateConfigKey('ttsInstructions', settings.ttsInstructions || ''),
]);
}, [updateConfigKey]);
if (settings.language) setDocumentLanguage(settings.language);
}, [updateConfigKey, setDocumentLanguage]);
const handleRefresh = useCallback(() => {
didAutoScrollOnOpenRef.current = false;
@ -885,15 +890,14 @@ function SegmentsListSkeletonRows() {
function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
return (
<Popover className="relative shrink-0">
<PopoverButton
as={IconButton}
<PopoverRoot className="relative shrink-0">
<PopoverIconTrigger
size="sm"
aria-label="Segment metadata"
title="Metadata"
>
<InfoIcon className="w-3.5 h-3.5" />
</PopoverButton>
</PopoverIconTrigger>
<Transition
as={Fragment}
enter="transition ease-standard duration-fast"
@ -960,7 +964,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
</dl>
</PopoverSurface>
</Transition>
</Popover>
</PopoverRoot>
);
}

View file

@ -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({
</a>
);
}
export function InlineButton({
className,
children,
type = 'button',
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & {
children: ReactNode;
}) {
return (
<button
type={type}
className={cn('underline hover:text-foreground', focusRing, motionColors, className)}
{...props}
>
{children}
</button>
);
}

View file

@ -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<InputHTMLAttributes<HTMLInputElement>, 'type'>
>(function Checkbox({ className, ...props }, ref) {
return <input ref={ref} type="checkbox" className={cn(checkboxClass, className)} {...props} />;
});

View file

@ -1,5 +1,6 @@
export * from './badge';
export * from './button';
export * from './checkbox';
export * from './choice-tile';
export * from './cn';
export * from './dialog';

View file

@ -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<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
className,
controlSize = 'md',

View file

@ -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<HTMLDivElement> & { children: ReactNode }) {
export const MenuRoot = HeadlessMenu;
export const MenuTrigger = HeadlessMenuButton;
export function MenuTransition({ children }: { children: ReactNode }) {
return (
<div className={cn(menuPanelClass, className)} {...props}>
<Transition
as={Fragment}
enter="transition ease-standard duration-fast"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-standard duration-fast"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
{children}
</div>
</Transition>
);
}

View file

@ -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<typeof IconButton>) {
return (
<PopoverButton as={IconButton} {...props}>
{children}
</PopoverButton>
);
}
export function PopoverSurface({
className,
children,

View file

@ -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<T = SelectOption>({
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<string, unknown>;
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<string, unknown>;
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 (
<Listbox value={value} onChange={onChange}>
<SharedListboxButton>
<span>{activeOption?.label ?? 'Select'}</span>
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft">
<ChevronRightIcon className="h-4 w-4 rotate-90" aria-hidden="true" />
<Listbox value={value} onChange={onChange} disabled={disabled}>
<SharedListboxButton className={buttonClassName}>
<span className="block truncate">{value === undefined ? placeholder : valueRenderer(value)}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className={chevronClassName} aria-hidden="true" />
</span>
</SharedListboxButton>
<SharedListboxOptions anchor="bottom">
{options.map((option) => (
<SharedListboxOption key={option.value} value={option.value}>
{({ selected }) => (
<>
<span className="absolute left-2 flex items-center text-accent">
{selected ? <CheckIcon className="h-4 w-4" aria-hidden="true" /> : null}
</span>
<span>{option.label}</span>
</>
)}
</SharedListboxOption>
))}
</SharedListboxOptions>
<Transition
as={Fragment}
leave="transition ease-standard duration-fast"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<SharedListboxOptions anchor="bottom start" className={optionsClassName}>
{options.map((option) => (
<SharedListboxOption
key={optionKey(option)}
value={option}
inset={optionInset}
itemClassName={optionItemClassName}
>
{({ selected }) => (
<>
{optionRenderer(option, { selected })}
{showCheckmark && selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</SharedListboxOption>
))}
</SharedListboxOptions>
</Transition>
</Listbox>
);
}

View file

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

View file

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

View file

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

View file

@ -4,11 +4,15 @@ import { useCallback, useEffect, type MutableRefObject, type RefObject } from 'r
import type { Rendition } from 'epubjs';
import {
buildMonotonicWordToTokenMap,
buildWordHighlightCacheKey,
resolveAlignmentWordSourceRange,
tokenizeCanonicalSegment,
type EpubCanonicalWordToken,
} from '@/lib/client/epub/epub-word-highlight';
import {
buildAlignmentTokenRanges,
type HighlightTokenRange,
} from '@/lib/client/highlight-token-alignment';
import {
createRangeFromMappedOffsets,
resolveVisibleSegmentRange,
@ -19,7 +23,7 @@ import type { TTSSentenceAlignment } from '@/types/tts';
export type EpubWordHighlightMapCache = {
key: string;
wordToToken: number[];
wordToTokenRange: Array<HighlightTokenRange | null>;
tokens: EpubCanonicalWordToken[];
};
@ -30,6 +34,7 @@ type UseEpubHighlightingParams = {
currentWordHighlightCfiRef: MutableRefObject<string | null>;
renderedTextMapsRef: MutableRefObject<EpubRenderedTextMap[]>;
wordHighlightMapCacheRef: MutableRefObject<EpubWordHighlightMapCache | null>;
language?: string;
};
type UseEpubHighlightingResult = {
@ -52,6 +57,7 @@ export function useEPUBHighlighting({
currentWordHighlightCfiRef,
renderedTextMapsRef,
wordHighlightMapCacheRef,
language,
}: UseEpubHighlightingParams): UseEpubHighlightingResult {
const clearWordHighlights = useCallback(() => {
if (!renditionRef.current) return;
@ -116,25 +122,68 @@ export function useEPUBHighlighting({
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return;
const cacheKey = buildWordHighlightCacheKey(segment, alignment);
const alignmentRange = resolveAlignmentWordSourceRange(segment, words[wordIndex]);
if (
alignmentRange
&& alignmentRange.sourceStart >= resolved.startOffset
&& alignmentRange.sourceEnd <= resolved.endOffset
) {
const wordRange = createRangeFromMappedOffsets(
resolved.map,
alignmentRange.sourceStart,
alignmentRange.sourceEnd,
);
if (wordRange) {
try {
const wordCfi = resolved.map.content.cfiFromRange(wordRange);
currentWordHighlightCfiRef.current = wordCfi;
renditionRef.current.annotations.add(
'highlight',
wordCfi,
{},
() => { },
'',
{
fill: 'var(--accent)',
'fill-opacity': '0.4',
'mix-blend-mode': 'multiply',
}
);
return;
} catch (error) {
console.error('Error highlighting EPUB word from alignment offsets:', error);
}
}
}
const cacheKey = buildWordHighlightCacheKey(segment, alignment, language);
if (wordHighlightMapCacheRef.current?.key !== cacheKey) {
const tokens = tokenizeCanonicalSegment(segment);
const tokens = tokenizeCanonicalSegment(segment, language);
wordHighlightMapCacheRef.current = {
key: cacheKey,
tokens,
wordToToken: buildMonotonicWordToTokenMap(words, tokens),
wordToTokenRange: buildAlignmentTokenRanges(
words,
tokens.map((token) => token.norm),
{ minimumSimilarity: 0.8 },
),
};
}
const cached = wordHighlightMapCacheRef.current;
const tokenIndex = cached.wordToToken[wordIndex] ?? -1;
if (tokenIndex < 0) return;
const tokenRange = cached.wordToTokenRange[wordIndex];
if (!tokenRange) return;
const token = cached.tokens[tokenIndex];
if (!token) return;
if (token.sourceStart < resolved.startOffset || token.sourceEnd > resolved.endOffset) return;
const firstToken = cached.tokens[tokenRange.start];
const lastToken = cached.tokens[tokenRange.end];
if (!firstToken || !lastToken) return;
if (firstToken.sourceStart < resolved.startOffset || lastToken.sourceEnd > resolved.endOffset) return;
const wordRange = createRangeFromMappedOffsets(resolved.map, token.sourceStart, token.sourceEnd);
const wordRange = createRangeFromMappedOffsets(
resolved.map,
firstToken.sourceStart,
lastToken.sourceEnd,
);
if (!wordRange) return;
try {
@ -162,6 +211,7 @@ export function useEPUBHighlighting({
renderedTextMapsRef,
renditionRef,
wordHighlightMapCacheRef,
language,
]);
const setRenderedTextMaps = useCallback((maps: EpubRenderedTextMap[]) => {

View file

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

View file

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

View file

@ -1,5 +1,7 @@
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
import type { TTSSentenceAlignment } from '@/types/tts';
import type { TTSSentenceAlignment, TTSSentenceWord } from '@/types/tts';
import { segmentWords } from '@/lib/shared/language';
import { normalizeHighlightToken } from '@/lib/client/highlight-token-alignment';
export type EpubCanonicalWordToken = {
norm: string;
@ -8,107 +10,43 @@ export type EpubCanonicalWordToken = {
};
export const normalizeWordForHighlight = (text: string): string =>
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
normalizeHighlightToken(text);
export const tokenizeCanonicalSegment = (segment: CanonicalTtsSegment): EpubCanonicalWordToken[] => {
const tokens: EpubCanonicalWordToken[] = [];
const wordRegex = /\S+/g;
let match: RegExpExecArray | null;
export const resolveAlignmentWordSourceRange = (
segment: CanonicalTtsSegment,
word: TTSSentenceWord,
): { sourceStart: number; sourceEnd: number } | null => {
const { charStart, charEnd } = word;
if (!Number.isInteger(charStart) || !Number.isInteger(charEnd)) return null;
if (charStart < 0 || charEnd <= charStart || charEnd > segment.text.length) return null;
while ((match = wordRegex.exec(segment.text)) !== null) {
const raw = match[0];
const leading = raw.match(/^[^A-Za-z0-9]*/)?.[0].length ?? 0;
const trailing = raw.match(/[^A-Za-z0-9]*$/)?.[0].length ?? 0;
const start = match.index + leading;
const end = match.index + raw.length - trailing;
if (end <= start) continue;
const norm = normalizeWordForHighlight(raw.slice(leading, raw.length - trailing));
if (!norm) continue;
tokens.push({
norm,
sourceStart: segment.startAnchor.offset + start,
sourceEnd: segment.startAnchor.offset + end,
});
}
return tokens;
return {
sourceStart: segment.startAnchor.offset + charStart,
sourceEnd: segment.startAnchor.offset + charEnd,
};
};
export const buildMonotonicWordToTokenMap = (
alignmentWords: TTSSentenceAlignment['words'],
segmentTokens: EpubCanonicalWordToken[],
): number[] => {
const alignmentTokens = alignmentWords.map((word) => normalizeWordForHighlight(word.text));
const wordToToken = new Array<number>(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<number>(n + 1).fill(0));
const bt: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
for (let i = 1; i <= m; i += 1) {
for (let j = 1; j <= n; j += 1) {
let best = dp[i - 1][j - 1];
let move = 0;
const alignmentNorm = alignmentTokens[i - 1];
const segmentNorm = segmentTokens[j - 1].norm;
if (alignmentNorm && alignmentNorm === segmentNorm) {
const positionPenalty =
m <= 1 || n <= 1
? 0
: Math.abs((i - 1) / (m - 1) - (j - 1) / (n - 1));
best = dp[i - 1][j - 1] + 10 - positionPenalty;
move = 1;
}
if (dp[i - 1][j] > best) {
best = dp[i - 1][j];
move = 2;
}
if (dp[i][j - 1] > best) {
best = dp[i][j - 1];
move = 3;
}
dp[i][j] = best;
bt[i][j] = move;
}
}
let i = m;
let j = n;
while (i > 0 && j > 0) {
const move = bt[i][j];
if (move === 1) {
wordToToken[i - 1] = j - 1;
i -= 1;
j -= 1;
} else if (move === 2) {
i -= 1;
} else if (move === 3) {
j -= 1;
} else {
i -= 1;
j -= 1;
}
}
return wordToToken;
};
export const tokenizeCanonicalSegment = (
segment: CanonicalTtsSegment,
language?: string,
): EpubCanonicalWordToken[] =>
segmentWords(segment.text, language)
.map((token) => ({
norm: normalizeWordForHighlight(token.text),
sourceStart: segment.startAnchor.offset + token.start,
sourceEnd: segment.startAnchor.offset + token.end,
}))
.filter((token) => Boolean(token.norm));
export const buildWordHighlightCacheKey = (
segment: CanonicalTtsSegment,
alignment: TTSSentenceAlignment,
language?: string,
): string =>
[
segment.key,
segment.text.length,
language || '',
alignment.words.length,
alignment.words.map((word) => normalizeWordForHighlight(word.text)).join('|'),
].join('::');

View file

@ -0,0 +1,255 @@
import { CmpStr } from 'cmpstr';
import { normalizeUnicodeToken } from '@/lib/shared/language';
import type { TTSSentenceAlignment } from '@/types/tts';
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
export interface HighlightTokenRange {
start: number;
end: number;
}
export interface HighlightTokenMatchResult extends HighlightTokenRange {
rating: number;
lengthDiff: number;
}
export function normalizeHighlightToken(text: string): string {
return normalizeUnicodeToken(text);
}
export function findBestHighlightTokenMatch(
patternTokens: string[],
tokenTexts: string[],
): HighlightTokenMatchResult {
const normalizedPattern = patternTokens.map(normalizeHighlightToken).filter(Boolean);
const normalizedTargets = tokenTexts.map(normalizeHighlightToken);
const cleanPattern = normalizedPattern.join('');
const patternLen = cleanPattern.length;
const responseBase: HighlightTokenMatchResult = {
start: -1,
end: -1,
rating: 0,
lengthDiff: Number.POSITIVE_INFINITY,
};
if (!patternLen || !normalizedTargets.length) return responseBase;
// Most viewer highlights are exact token sequences. Resolve those in one
// linear pass before entering the fuzzy window search, which is expensive
// enough to stall the UI when the target is a full HTML/TXT/MD document.
const firstPatternToken = normalizedPattern[0];
for (let start = 0; start < normalizedTargets.length; start += 1) {
if (normalizedTargets[start] !== firstPatternToken) continue;
let matches = true;
for (let offset = 1; offset < normalizedPattern.length; offset += 1) {
if (normalizedTargets[start + offset] !== normalizedPattern[offset]) {
matches = false;
break;
}
}
if (matches) {
return {
start,
end: start + normalizedPattern.length - 1,
rating: 1,
lengthDiff: 0,
};
}
}
const patternTokenCount = normalizedPattern.length;
const minWindowTokens = Math.max(1, Math.floor(patternTokenCount * 0.6));
const maxWindowTokens = Math.max(
minWindowTokens,
Math.ceil(patternTokenCount * 1.4),
);
let bestStart = -1;
let bestEnd = -1;
let bestRating = 0;
let bestLengthDiff = Number.POSITIVE_INFINITY;
for (let start = 0; start < normalizedTargets.length; start += 1) {
let combined = '';
for (
let offset = 0;
offset < maxWindowTokens && start + offset < normalizedTargets.length;
offset += 1
) {
combined += normalizedTargets[start + offset];
const windowSize = offset + 1;
if (windowSize < minWindowTokens) continue;
if (combined.length > patternLen * 2) break;
const similarity = cmp.compare(combined, cleanPattern);
const lengthDiff = Math.abs(combined.length - patternLen);
const lengthPenalty = lengthDiff / patternLen;
const adjustedRating = similarity * (1 - lengthPenalty * 0.3);
let boostedRating = adjustedRating;
const maxPrefixCheck = Math.min(windowSize, normalizedPattern.length, 5);
let prefixMatches = 0;
for (let i = 0; i < maxPrefixCheck; i += 1) {
const tokenSim = cmp.compare(normalizedTargets[start + i], normalizedPattern[i]);
if (tokenSim < 0.8) break;
prefixMatches += 1;
}
if (prefixMatches > 0) {
boostedRating = adjustedRating * (1 + (prefixMatches / maxPrefixCheck) * 0.25);
}
if (
boostedRating > bestRating
|| (
Math.abs(boostedRating - bestRating) < 1e-3
&& lengthDiff < bestLengthDiff
)
) {
bestRating = boostedRating;
bestLengthDiff = lengthDiff;
bestStart = start;
bestEnd = start + offset;
}
}
}
return {
start: bestStart,
end: bestEnd,
rating: bestRating,
lengthDiff: bestLengthDiff,
};
}
function buildExactConcatenatedRanges(
alignmentNorms: string[],
targetNorms: string[],
): Array<HighlightTokenRange | null> | 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<HighlightTokenRange | null> = [];
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<HighlightTokenRange | null> {
const alignmentNorms = alignmentWords.map((word) => normalizeHighlightToken(word.text));
const targetNorms = targetTexts.map(normalizeHighlightToken);
const ranges = new Array<HighlightTokenRange | null>(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<number>(n + 1).fill(Number.POSITIVE_INFINITY),
);
const bt: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(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;
}

View file

@ -11,13 +11,18 @@
* sentence
* - `WORD` saturated background on the currently-spoken word
*
* Word-to-DOM alignment is done via Needleman-Wunsch (same approach the PDF
* reader uses) so DOM token counts that diverge from whisper's word count
* still produce a smooth, monotonic word highlight rather than a proportional
* approximation that snaps around when the counts disagree.
* Word-to-DOM alignment uses the shared viewer token-range mapper so DOM token
* counts that diverge from the timed alignment still produce a smooth,
* monotonic highlight across languages.
*/
import { CmpStr } from 'cmpstr';
import type { TTSSentenceAlignment } from '@/types/tts';
import { segmentWords } from '@/lib/shared/language';
import {
buildAlignmentTokenRanges,
findBestHighlightTokenMatch,
normalizeHighlightToken,
type HighlightTokenRange,
} from '@/lib/client/highlight-token-alignment';
export const HTML_SENTENCE_CLASS = 'openreader-html-highlight-sentence';
export const HTML_WORD_CLASS = 'openreader-html-highlight-word';
@ -29,8 +34,6 @@ interface DomToken {
norm: string;
}
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
let sentenceWraps: HTMLSpanElement[] = [];
let wordWraps: HTMLSpanElement[] = [];
@ -45,29 +48,19 @@ interface SentenceState {
// is in place. Stable across word wrap/unwrap cycles because clear() calls
// `parent.normalize()` which restores the original text-node structure.
wordTokens: DomToken[];
// For an alignment we've already seen, the cached wordIndex → tokenIndex map.
// For an alignment we've already seen, the cached wordIndex → token range map.
alignment: TTSSentenceAlignment | null;
wordToToken: number[] | null;
wordToTokenRange: Array<HighlightTokenRange | null> | null;
}
let sentenceState: SentenceState | null = null;
function normalizeWord(word: string): string {
return word
.toLowerCase()
.replace(/[\p{P}\p{S}]+/gu, '')
.trim();
return normalizeHighlightToken(word);
}
function tokenizePattern(pattern: string): string[] {
const out: string[] = [];
const wordRe = /\S+/g;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(pattern)) !== null) {
const norm = normalizeWord(m[0]);
if (norm) out.push(norm);
}
return out;
function tokenizePattern(pattern: string, language?: string): string[] {
return segmentWords(pattern, language).map((token) => normalizeWord(token.text)).filter(Boolean);
}
function unwrap(span: HTMLSpanElement): void {
@ -108,7 +101,11 @@ function isHighlightWrapper(node: Node | null): node is HTMLSpanElement {
return el.classList.contains(HTML_SENTENCE_CLASS) || el.classList.contains(HTML_WORD_CLASS);
}
function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean } = { skipHighlightWraps: true }): DomToken[] {
function collectDomTokens(
root: HTMLElement,
language?: string,
opts: { skipHighlightWraps: boolean } = { skipHighlightWraps: true },
): DomToken[] {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
@ -131,15 +128,13 @@ function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean
while (current) {
const textNode = current as Text;
const text = textNode.nodeValue || '';
const wordRe = /\S+/g;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(text)) !== null) {
const norm = normalizeWord(m[0]);
for (const token of segmentWords(text, language)) {
const norm = normalizeWord(token.text);
if (!norm) continue;
tokens.push({
textNode,
startOffset: m.index,
endOffset: m.index + m[0].length,
startOffset: token.start,
endOffset: token.end,
norm,
});
}
@ -153,7 +148,7 @@ function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean
* wrap is applied; lets us index just the words *within* the highlighted
* sentence rather than the whole document).
*/
function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
function collectTokensInsideWraps(wraps: HTMLSpanElement[], language?: string): DomToken[] {
const tokens: DomToken[] = [];
for (const wrap of wraps) {
const walker = document.createTreeWalker(wrap, NodeFilter.SHOW_TEXT);
@ -161,15 +156,13 @@ function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
while (current) {
const t = current as Text;
const text = t.nodeValue || '';
const wordRe = /\S+/g;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(text)) !== null) {
const norm = normalizeWord(m[0]);
for (const token of segmentWords(text, language)) {
const norm = normalizeWord(token.text);
if (!norm) continue;
tokens.push({
textNode: t,
startOffset: m.index,
endOffset: m.index + m[0].length,
startOffset: token.start,
endOffset: token.end,
norm,
});
}
@ -180,43 +173,9 @@ function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
}
function findBestWindow(tokens: DomToken[], patternTokens: string[]): { start: number; end: number } | null {
if (!tokens.length || !patternTokens.length) return null;
const pLen = patternTokens.length;
let bestStart = -1;
let bestEnd = -1;
let bestScore = 0;
for (let i = 0; i + Math.max(1, Math.ceil(pLen * 0.5)) - 1 < tokens.length; i += 1) {
if (tokens[i].norm !== patternTokens[0]) continue;
let matches = 1;
let domCursor = i + 1;
for (let p = 1; p < pLen && domCursor < tokens.length; p += 1) {
let stepped = false;
for (let k = 0; k < 3 && domCursor + k < tokens.length; k += 1) {
if (tokens[domCursor + k].norm === patternTokens[p]) {
matches += 1;
domCursor += k + 1;
stepped = true;
break;
}
}
if (!stepped) domCursor += 1;
}
const end = Math.min(tokens.length - 1, domCursor - 1);
const score = matches / pLen;
if (score > bestScore) {
bestScore = score;
bestStart = i;
bestEnd = end;
if (score >= 0.95) break;
}
}
if (bestScore >= 0.5 && bestStart !== -1) {
return { start: bestStart, end: bestEnd };
}
return null;
const match = findBestHighlightTokenMatch(patternTokens, tokens.map((token) => token.norm));
if (match.start === -1 || match.rating < 0.5) return null;
return { start: match.start, end: match.end };
}
function wrapTokenRange(tokens: DomToken[], start: number, end: number, className: string): HTMLSpanElement[] {
@ -260,14 +219,15 @@ function wrapTokenRange(tokens: DomToken[], start: number, end: number, classNam
export function highlightHtmlSentence(
container: HTMLElement | null | undefined,
sentence: string | null | undefined,
language?: string,
): boolean {
clearHtmlSentenceHighlight();
if (!container || !sentence?.trim()) return false;
const patternTokens = tokenizePattern(sentence);
const patternTokens = tokenizePattern(sentence, language);
if (!patternTokens.length) return false;
const domTokens = collectDomTokens(container);
const domTokens = collectDomTokens(container, language);
if (!domTokens.length) return false;
const win = findBestWindow(domTokens, patternTokens);
@ -280,118 +240,13 @@ export function highlightHtmlSentence(
// can look up individual word tokens without re-walking the doc.
sentenceState = {
sentence,
wordTokens: collectTokensInsideWraps(sentenceWraps),
wordTokens: collectTokensInsideWraps(sentenceWraps, language),
alignment: null,
wordToToken: null,
wordToTokenRange: null,
};
return true;
}
/**
* Build a wordIndex tokenIndex map via Needleman-Wunsch alignment between
* whisper's word list and the DOM tokens inside the sentence. Mirrors the
* approach in `src/lib/client/pdf.ts#highlightWordIndex` so PDF and HTML
* highlights behave the same way under count mismatches (contractions,
* stripped punctuation, missing whitespace, etc.).
*/
function buildAlignmentMap(
alignment: TTSSentenceAlignment,
domTokens: DomToken[],
): number[] {
const words = alignment.words || [];
const wordToToken = new Array<number>(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<number>(n + 1).fill(Number.POSITIVE_INFINITY),
);
const bt: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0),
); // 0=diag (substitute), 1=up (skip dom), 2=left (skip tts)
dp[0][0] = 0;
const GAP_COST = 0.7;
for (let i = 0; i <= m; i += 1) {
for (let j = 0; j <= n; j += 1) {
if (i > 0 && j > 0) {
const a = domFiltered[i - 1].norm;
const b = ttsFiltered[j - 1].norm;
const sim = a === b ? 1 : cmp.compare(a, b);
const cand = dp[i - 1][j - 1] + (1 - sim);
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 0;
}
}
if (i > 0) {
const cand = dp[i - 1][j] + GAP_COST;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 1;
}
}
if (j > 0) {
const cand = dp[i][j - 1] + GAP_COST;
if (cand < dp[i][j]) {
dp[i][j] = cand;
bt[i][j] = 2;
}
}
}
}
let i = m;
let j = n;
while (i > 0 || j > 0) {
const move = bt[i][j];
if (i > 0 && j > 0 && move === 0) {
const domIdx = domFiltered[i - 1].tokenIndex;
const ttsIdx = ttsFiltered[j - 1].wordIndex;
if (wordToToken[ttsIdx] === -1) wordToToken[ttsIdx] = domIdx;
i -= 1;
j -= 1;
} else if (i > 0 && (move === 1 || j === 0)) {
i -= 1;
} else if (j > 0 && (move === 2 || i === 0)) {
j -= 1;
} else {
break;
}
}
// Forward-fill, then backward-fill, so every wordIndex has a nearest known
// DOM token. This keeps the word highlight stable when whisper emits a
// word that didn't survive normalization (e.g. an apostrophe-only token).
let lastSeen = -1;
for (let k = 0; k < wordToToken.length; k += 1) {
if (wordToToken[k] !== -1) lastSeen = wordToToken[k];
else if (lastSeen !== -1) wordToToken[k] = lastSeen;
}
let nextSeen = -1;
for (let k = wordToToken.length - 1; k >= 0; k -= 1) {
if (wordToToken[k] !== -1) nextSeen = wordToToken[k];
else if (nextSeen !== -1) wordToToken[k] = nextSeen;
}
return wordToToken;
}
export function highlightHtmlWord(
container: HTMLElement | null | undefined,
alignment: TTSSentenceAlignment | undefined,
@ -411,16 +266,20 @@ export function highlightHtmlWord(
if (!words.length || wordIndex >= words.length) return false;
// (Re)build the alignment map when this is a new alignment object.
if (sentenceState.alignment !== alignment || !sentenceState.wordToToken) {
if (sentenceState.alignment !== alignment || !sentenceState.wordToTokenRange) {
sentenceState.alignment = alignment;
sentenceState.wordToToken = buildAlignmentMap(alignment, sentenceState.wordTokens);
sentenceState.wordToTokenRange = buildAlignmentTokenRanges(
alignment.words,
sentenceState.wordTokens.map((token) => token.norm),
{ fillGaps: true },
);
}
const tokenIndex = sentenceState.wordToToken[wordIndex];
if (tokenIndex === undefined || tokenIndex < 0) return false;
if (tokenIndex >= sentenceState.wordTokens.length) return false;
const tokenRange = sentenceState.wordToTokenRange[wordIndex];
if (!tokenRange) return false;
if (tokenRange.start < 0 || tokenRange.end >= sentenceState.wordTokens.length) return false;
wordWraps = wrapTokenRange(sentenceState.wordTokens, tokenIndex, tokenIndex, HTML_WORD_CLASS);
wordWraps = wrapTokenRange(sentenceState.wordTokens, tokenRange.start, tokenRange.end, HTML_WORD_CLASS);
return wordWraps.length > 0;
}

View file

@ -1,13 +1,11 @@
/// <reference lib="webworker" />
import { CmpStr } from 'cmpstr';
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
import { findBestHighlightTokenMatch } from './highlight-token-alignment';
interface TokenMatchRequest {
id: string;
type: 'tokenMatch';
pattern: string;
patternTokens: string[];
tokenTexts: string[];
}
@ -39,112 +37,17 @@ self.onmessage = (event: MessageEvent<TokenMatchRequest>) => {
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 12 words.
let boostedRating = adjustedRating;
if (patternTokens.length > 0) {
const windowTokens = tokenTexts.slice(start, start + windowSize);
const maxPrefixCheck = Math.min(
windowTokens.length,
patternTokens.length,
5
);
let prefixMatches = 0;
for (let i = 0; i < maxPrefixCheck; i++) {
const tokenText = windowTokens[i];
const patternToken = patternTokens[i];
// Require reasonably strong similarity for a prefix match
const tokenSim = cmp.compare(tokenText, patternToken);
if (tokenSim >= 0.8) {
prefixMatches++;
} else {
// Stop at the first non-matching leading token
break;
}
}
if (prefixMatches > 0) {
const prefixRatio = prefixMatches / maxPrefixCheck;
const PREFIX_BOOST_FACTOR = 0.25; // up to +25% boost
boostedRating = adjustedRating * (1 + prefixRatio * PREFIX_BOOST_FACTOR);
}
}
if (
boostedRating > bestRating ||
(Math.abs(boostedRating - bestRating) < 1e-3 &&
lengthDiff < bestLengthDiff)
) {
bestRating = boostedRating;
bestLengthDiff = lengthDiff;
bestStart = start;
bestEnd = start + offset;
}
}
}
const { id, patternTokens, tokenTexts } = data;
const result = findBestHighlightTokenMatch(patternTokens, tokenTexts);
const response: TokenMatchResponse = {
...responseBase,
bestStart,
bestEnd,
rating: bestRating,
lengthDiff: bestLengthDiff,
id,
type: 'tokenMatchResult',
bestStart: result.start,
bestEnd: result.end,
rating: result.rating,
lengthDiff: result.lengthDiff,
};
(self as unknown as DedicatedWorkerGlobalScope).postMessage(response);
};
};

View file

@ -3,16 +3,18 @@ import { TextLayer } from 'pdfjs-dist';
import "core-js/proposals/promise-with-resolvers";
import type { TTSSentenceAlignment } from '@/types/tts';
import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf';
import { CmpStr } from 'cmpstr';
import type { TTSSegmentLocator } from '@/types/client';
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
import { segmentWords } from '@/lib/shared/language';
import {
buildAlignmentTokenRanges,
type HighlightTokenRange,
} from '@/lib/client/highlight-token-alignment';
// Worker coordination for offloading highlight token matching
interface HighlightTokenMatchRequest {
id: string;
type: 'tokenMatch';
pattern: string;
patternTokens: string[];
tokenTexts: string[];
}
@ -45,7 +47,7 @@ function getHighlightWorker(): Worker | null {
}
function runHighlightTokenMatch(
pattern: string,
patternTokens: string[],
tokenTexts: string[]
): Promise<HighlightTokenMatchResponse | null> {
const worker = getHighlightWorker();
@ -70,7 +72,7 @@ function runHighlightTokenMatch(
const message: HighlightTokenMatchRequest = {
id,
type: 'tokenMatch',
pattern,
patternTokens,
tokenTexts,
};
worker.postMessage(message);
@ -151,7 +153,7 @@ let lastSpanNodes: HTMLElement[] = [];
let lastTokens: PDFToken[] = [];
let lastSentenceTokenWindow: { start: number; end: number } | null = null;
let lastSentencePattern: string | null = null;
let lastSentenceWordToTokenMap: number[] | null = null;
let lastSentenceWordToTokenRangeMap: Array<HighlightTokenRange | null> | null = null;
function getOrCreateHighlightLayer(span: HTMLElement): {
layer: HTMLElement;
@ -180,12 +182,6 @@ function getOrCreateHighlightLayer(span: HTMLElement): {
return { layer, pageElement, pageRect: pageElement.getBoundingClientRect() };
}
const normalizeWordForMatch = (text: string): string =>
text
.trim()
.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '')
.toLowerCase();
// Highlighting functions
let highlightPatternSeq = 0;
@ -193,6 +189,7 @@ type HighlightPatternOptions = {
parsedDocument?: ParsedPdfDocument | null;
locator?: TTSSegmentLocator | null;
useBlockGeometryOnly?: boolean;
language?: string;
};
function getHighlightLayerForPage(pageElement: HTMLElement): {
@ -422,7 +419,7 @@ export function highlightPattern(
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
if (!cleanPattern) return;
lastSentencePattern = cleanPattern;
lastSentenceWordToTokenMap = null;
lastSentenceWordToTokenRangeMap = null;
lastSentenceTokenWindow = null;
const parsedDocument = options?.parsedDocument ?? null;
const locator = options?.locator ?? null;
@ -451,17 +448,13 @@ export function highlightPattern(
const textNode = node as Text;
const textContent = textNode.textContent || '';
const wordRegex = /\S+/g;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(textContent)) !== null) {
const word = match[0];
for (const token of segmentWords(textContent, options?.language)) {
tokens.push({
spanIndex,
textNode,
text: word,
startOffset: match.index,
endOffset: match.index + word.length,
text: token.text,
startOffset: token.start,
endOffset: token.end,
});
}
});
@ -617,9 +610,10 @@ export function highlightPattern(
};
const tokenTexts = tokens.map((t) => t.text);
const patternTokens = segmentWords(cleanPattern, options?.language).map((token) => token.text);
// Fire-and-forget async worker call; UI thread returns immediately
runHighlightTokenMatch(cleanPattern, tokenTexts)
runHighlightTokenMatch(patternTokens, tokenTexts)
.then((result) => {
if (seq !== highlightPatternSeq) return;
if (!result || result.bestStart === -1) {
@ -676,158 +670,61 @@ export function highlightWordIndex(
const end = lastSentenceTokenWindow.end;
if (end < start) return;
// Lazily build or refresh the mapping from alignment word
// indices to PDF token indices for this sentence window.
// Lazily build or refresh the shared mapping from alignment words to PDF
// token ranges for this sentence window.
if (
!lastSentenceWordToTokenMap ||
lastSentenceWordToTokenMap.length !== words.length
!lastSentenceWordToTokenRangeMap ||
lastSentenceWordToTokenRangeMap.length !== words.length
) {
const pdfFiltered: { tokenIndex: number; norm: string }[] = [];
for (let i = start; i <= end; i++) {
const norm = normalizeWordForMatch(lastTokens[i].text);
if (!norm) continue;
pdfFiltered.push({ tokenIndex: i, norm });
}
const ttsFiltered: { wordIndex: number; norm: string }[] = [];
for (let i = 0; i < words.length; i++) {
const norm = normalizeWordForMatch(words[i].text);
if (!norm) continue;
ttsFiltered.push({ wordIndex: i, norm });
}
const wordToToken = new Array<number>(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<number>(n + 1).fill(Number.POSITIVE_INFINITY)
);
const bt: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(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
}
}
}

View file

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

View file

@ -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<SpeechCreateParams, 'voice'> & {
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<string, number>({
max: REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES,
});
const openAiCompatibleLanguageUnsupported = new LRUCache<string, true>({ 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<R
if (request.instructions) {
input.prompt = request.instructions;
}
return input;
return addReplicateLanguageInput(input, request);
}
if (model === 'minimax/speech-2.8-turbo') {
@ -445,7 +457,7 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<R
if (request.speed !== 1) {
input.speed = Math.max(0.5, Math.min(2.0, request.speed));
}
return input;
return addReplicateLanguageInput(input, request);
}
if (model === 'qwen/qwen3-tts') {
@ -457,7 +469,7 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<R
if (request.instructions) {
input.style_instruction = request.instructions;
}
return input;
return addReplicateLanguageInput(input, request);
}
if (model === 'inworld/tts-1.5-mini') {
@ -469,7 +481,7 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<R
if (request.speed !== 1) {
input.speaking_rate = request.speed;
}
return input;
return addReplicateLanguageInput(input, request);
}
const input: Record<string, unknown> = { text: request.text };
@ -498,9 +510,46 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<R
input.instructions = request.instructions;
}
return addReplicateLanguageInput(input, request);
}
async function addReplicateLanguageInput(
input: Record<string, unknown>,
request: ResolvedServerTTSRequest,
): Promise<Record<string, unknown>> {
if (!request.language) return input;
const languageInput = await resolveReplicateLanguageInput({
provider: 'replicate',
model: request.model as string,
apiKey: request.apiKey,
});
if (languageInput) {
const resolvedValue = resolveReplicateLanguageValue(request.language, languageInput.allowedValues);
if (resolvedValue) {
input[languageInput.key] = resolvedValue;
}
}
return input;
}
export function resolveReplicateLanguageValue(language: string, allowedValues: string[]): string | null {
if (allowedValues.length === 0) return language;
const baseLanguage = toBaseLanguageCode(language);
const candidates = [
language,
baseLanguage,
getLanguageDisplayName(language),
getLanguageDisplayName(baseLanguage),
];
const allowedByLowercase = new Map(
allowedValues.map((value) => [value.toLocaleLowerCase(), value]),
);
return candidates
.map((candidate) => allowedByLowercase.get(candidate.toLocaleLowerCase()))
.find((candidate): candidate is string => Boolean(candidate)) ?? null;
}
async function runReplicateRequest(
request: ResolvedServerTTSRequest,
signal: AbortSignal,
@ -594,6 +643,31 @@ async function runProviderRequest(
createParams.instructions = request.instructions;
}
if (request.provider !== 'openai' && request.language) {
const supportKey = `${request.provider}|${request.baseUrl || ''}|${request.model as string}|${request.language}`;
if (!openAiCompatibleLanguageUnsupported.has(supportKey)) {
try {
return await fetchTTSBufferWithRetry(
openai,
{ ...createParams, language: request.language },
signal,
upstreamSettings.ttsUpstreamMaxRetries,
);
} catch (error) {
const status = getUpstreamStatus(error);
if (status !== 400 && status !== 422) throw error;
const fallback = await fetchTTSBufferWithRetry(
openai,
createParams,
signal,
upstreamSettings.ttsUpstreamMaxRetries,
);
openAiCompatibleLanguageUnsupported.set(supportKey, true);
return fallback;
}
}
}
return fetchTTSBufferWithRetry(openai, createParams, signal, upstreamSettings.ttsUpstreamMaxRetries);
}
@ -614,6 +688,7 @@ export async function generateTTSBuffer(
format: resolved.format,
text: resolved.text,
instructions: resolved.instructions,
language: resolved.language,
testNamespace: resolved.testNamespace,
});

View file

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

View file

@ -102,6 +102,19 @@ function walkRecordGraph(root: unknown, visit: (node: Record<string, unknown>) =
}
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<unknown | null> {
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<string, ReplicateVoiceInputKey>({
max: REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES,
});
const replicateLanguageInputCache = new LRUCache<string, ReplicateLanguageInput>({
max: REPLICATE_LANGUAGE_INPUT_KEY_CACHE_MAX_ENTRIES,
});
const replicateOpenApiSchemaPromiseCache = new LRUCache<string, Promise<unknown | null>>({
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<ReplicateLanguageInputKey | null> {
const input = await resolveReplicateLanguageInput({ provider, model, apiKey });
return input?.key ?? null;
}
export async function resolveReplicateLanguageInput({
provider,
model,
apiKey = '',
}: ResolveVoicesOptions): Promise<ReplicateLanguageInput | null> {
if (provider !== 'replicate' || REPLICATE_BUILT_IN_MODELS.has(model) || !apiKey) return null;
const cached = replicateLanguageInputCache.get(model);
if (cached) return cached;
const openApiSchema = await getReplicateOpenApiSchemaCached(apiKey, model);
const input = extractReplicateLanguageInputFromOpenApiSchema(openApiSchema);
if (input) {
replicateLanguageInputCache.set(model, input);
}
return input;
}
async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
const controller = new AbortController();
const timeoutId = setTimeout(() => {

View file

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

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

@ -0,0 +1,199 @@
import { isKokoroModel } from '@/lib/shared/kokoro';
export const DEFAULT_TTS_LANGUAGE = 'en';
export interface TextToken {
text: string;
start: number;
end: number;
}
const KOKORO_LANGUAGE_BY_PREFIX: Readonly<Record<string, string>> = {
af: 'en-US',
am: 'en-US',
bf: 'en-GB',
bm: 'en-GB',
ef: 'es',
em: 'es',
ff: 'fr',
hf: 'hi',
hm: 'hi',
if: 'it',
im: 'it',
jf: 'ja',
jm: 'ja',
pf: 'pt-BR',
pm: 'pt-BR',
zf: 'zh-CN',
zm: 'zh-CN',
};
export const KOKORO_SUPPORTED_LANGUAGES = [
'en',
'es',
'fr',
'hi',
'it',
'ja',
'pt-BR',
'zh-CN',
] as const;
export function normalizeLanguageTag(
language: string | null | undefined,
fallback = DEFAULT_TTS_LANGUAGE,
): string {
const candidate = language?.trim();
if (!candidate || candidate.toLowerCase() === 'auto') return fallback;
try {
return Intl.getCanonicalLocales(candidate)[0] ?? fallback;
} catch {
return fallback;
}
}
export function normalizeOptionalLanguageTag(language: unknown): string | null {
if (typeof language !== 'string') return null;
const candidate = language.trim().split(/[,\s]+/u)[0];
if (!candidate) return null;
try {
return Intl.getCanonicalLocales(candidate)[0] ?? null;
} catch {
return null;
}
}
export function toBaseLanguageCode(language: string | null | undefined): string {
const normalized = normalizeLanguageTag(language);
try {
return new Intl.Locale(normalized).language;
} catch {
return normalized.split('-')[0]?.toLowerCase() || DEFAULT_TTS_LANGUAGE;
}
}
export function inferKokoroLanguageFromVoice(voice: string | null | undefined): string | null {
const languages = new Set(getKokoroVoiceLanguages(voice));
return languages.size === 1 ? [...languages][0] : null;
}
export function getKokoroVoiceLanguages(voice: string | null | undefined): string[] {
if (!voice?.trim()) return [];
return Array.from(new Set(
voice
.split('+')
.map((part) => part.trim().replace(/\([^)]*\)/g, ''))
.map((name) => KOKORO_LANGUAGE_BY_PREFIX[name.slice(0, 2).toLowerCase()])
.filter((language): language is string => Boolean(language)),
));
}
export function keepKokoroVoicesInOneLanguage(
voices: string[],
preferredVoice?: string | null,
): string[] {
const preferredLanguage = getKokoroVoiceLanguages(preferredVoice)[0]
?? voices.flatMap((voice) => getKokoroVoiceLanguages(voice))[0];
if (!preferredLanguage) return voices;
const preferredBaseLanguage = toBaseLanguageCode(preferredLanguage);
return voices.filter((voice) => {
const voiceLanguage = getKokoroVoiceLanguages(voice)[0];
return !voiceLanguage || toBaseLanguageCode(voiceLanguage) === preferredBaseLanguage;
});
}
export function resolveTtsLanguage(input: {
configuredLanguage?: string | null;
voice?: string | null;
}): string {
const configured = input.configuredLanguage?.trim();
if (configured && configured.toLowerCase() !== 'auto') {
return normalizeLanguageTag(configured);
}
return inferKokoroLanguageFromVoice(input.voice) ?? DEFAULT_TTS_LANGUAGE;
}
export function getLanguageDisplayName(language: string): string {
try {
return new Intl.DisplayNames(['en'], { type: 'language' }).of(language) || language;
} catch {
return language;
}
}
export function getTtsLanguageCompatibilityWarnings(input: {
model?: string | null;
voice?: string | null;
documentLanguage?: string | null;
}): string[] {
if (!isKokoroModel(input.model || '')) return [];
const documentLanguage = normalizeLanguageTag(input.documentLanguage);
const documentBaseLanguage = toBaseLanguageCode(documentLanguage);
const supportedBaseLanguages = new Set(KOKORO_SUPPORTED_LANGUAGES.map((language) => toBaseLanguageCode(language)));
const voiceLanguages = getKokoroVoiceLanguages(input.voice);
const voiceBaseLanguages = new Set(voiceLanguages.map((language) => toBaseLanguageCode(language)));
const warnings: string[] = [];
if (!supportedBaseLanguages.has(documentBaseLanguage)) {
warnings.push(
`Kokoro's built-in voice catalog does not include ${getLanguageDisplayName(documentLanguage)}.`,
);
}
const voiceLanguage = voiceBaseLanguages.size === 1 ? voiceLanguages[0] : undefined;
if (voiceLanguage && toBaseLanguageCode(voiceLanguage) !== documentBaseLanguage) {
warnings.push(
`Selected Kokoro voice is ${getLanguageDisplayName(voiceLanguage)}, but the document is ${getLanguageDisplayName(documentLanguage)}.`,
);
}
return warnings;
}
export function normalizeUnicodeToken(text: string): string {
return text
.normalize('NFKC')
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '');
}
export function segmentSentences(text: string, language?: string | null): string[] {
const normalizedLanguage = normalizeLanguageTag(language);
try {
return [...new Intl.Segmenter(normalizedLanguage, { granularity: 'sentence' }).segment(text)]
.map(({ segment }) => segment.trim())
.filter(Boolean);
} catch {
return text.split(/(?<=[.!?؟])\s*/u).map((segment) => segment.trim()).filter(Boolean);
}
}
export function segmentWords(text: string, language?: string | null): TextToken[] {
const normalizedLanguage = normalizeLanguageTag(language);
try {
return [...new Intl.Segmenter(normalizedLanguage, { granularity: 'word' }).segment(text)]
.filter((segment) => segment.isWordLike)
.map((segment) => ({
text: segment.segment,
start: segment.index,
end: segment.index + segment.segment.length,
}));
} catch {
const tokens: TextToken[] = [];
const wordRegex = /\S+/gu;
let match: RegExpExecArray | null;
while ((match = wordRegex.exec(text)) !== null) {
tokens.push({
text: match[0],
start: match.index,
end: match.index + match[0].length,
});
}
return tokens;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,6 +15,7 @@ describe('coerceAudiobookGenerationSettings', () => {
postSpeed: 1,
format: 'mp3',
ttsInstructions: 'keep calm',
language: 'ja-jp',
});
expect(result.migrated).toBe(false);
@ -27,6 +28,7 @@ describe('coerceAudiobookGenerationSettings', () => {
postSpeed: 1,
format: 'mp3',
ttsInstructions: 'keep calm',
language: 'ja-JP',
});
});
@ -93,6 +95,7 @@ describe('coerceAudiobookGenerationSettings', () => {
postSpeed: 1,
format: 'mp3',
ttsInstructions: '',
language: 'en',
},
restrictUserApiKeys: true,
fallbackProviderRef: 'shared-openai',
@ -111,5 +114,6 @@ describe('coerceAudiobookGenerationSettings', () => {
expect(settings.providerType).toBe('openai');
expect(settings.ttsModel).toBe('gpt-4o-mini-tts');
expect(settings.ttsInstructions).toBe('Speak with warmth.');
expect(settings.language).toBe('en');
});
});

View file

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

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
buildMonotonicWordToTokenMap,
resolveAlignmentWordSourceRange,
tokenizeCanonicalSegment,
} from '../../src/lib/client/epub/epub-word-highlight';
import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan';
@ -18,16 +18,46 @@ const segment = (text: string, offset = 0): CanonicalTtsSegment => ({
spansSourceBoundary: false,
});
const alignmentWords = (words: string[]): TTSSentenceAlignment['words'] =>
words.map((word, index) => ({
text: word,
startSec: index,
endSec: index + 0.5,
charStart: 0,
charEnd: word.length,
}));
describe('EPUB word highlight mapping', () => {
test('tokenizes Japanese and Chinese using locale-aware word boundaries', () => {
const japanese = tokenizeCanonicalSegment(segment('これは日本語です。', 5), 'ja');
expect(japanese.length).toBeGreaterThan(1);
expect(japanese.every((token) => token.norm.length > 0)).toBe(true);
const chinese = tokenizeCanonicalSegment(segment('这是中文。', 10), 'zh');
expect(chinese.length).toBeGreaterThan(1);
expect(chinese.map((token) => token.norm).join('')).toBe('这是中文');
});
test('resolves Japanese alignment chunks directly from character offsets', () => {
const japanese = segment('これは日本語です。', 25);
const word: TTSSentenceAlignment['words'][number] = {
text: 'これは',
startSec: 0,
endSec: 0.5,
charStart: 0,
charEnd: 3,
};
expect(resolveAlignmentWordSourceRange(japanese, word)).toEqual({
sourceStart: 25,
sourceEnd: 28,
});
});
test('rejects invalid alignment character offsets so token mapping can be used', () => {
const japanese = segment('これは日本語です。', 25);
const word: TTSSentenceAlignment['words'][number] = {
text: '範囲外',
startSec: 0,
endSec: 0.5,
charStart: 20,
charEnd: 23,
};
expect(resolveAlignmentWordSourceRange(japanese, word)).toBeNull();
});
test('tokenizes canonical segment words with source offsets', () => {
const tokens = tokenizeCanonicalSegment(segment('"Hello," she said.', 12));
@ -38,23 +68,4 @@ describe('EPUB word highlight mapping', () => {
]);
});
test('maps repeated words monotonically instead of jumping to later duplicates', () => {
const tokens = tokenizeCanonicalSegment(segment('the light and the shadow and the light returned'));
const map = buildMonotonicWordToTokenMap(
alignmentWords(['the', 'light', 'and', 'the', 'shadow', 'and', 'the', 'light', 'returned']),
tokens,
);
expect(map).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
});
test('leaves unmatched alignment words unhighlighted instead of borrowing a neighbor', () => {
const tokens = tokenizeCanonicalSegment(segment('alpha beta gamma'));
const map = buildMonotonicWordToTokenMap(
alignmentWords(['alpha', 'missing', 'gamma']),
tokens,
);
expect(map).toEqual([0, -1, 2]);
});
});

View file

@ -0,0 +1,107 @@
import { describe, expect, test } from 'vitest';
import {
buildAlignmentTokenRanges,
findBestHighlightTokenMatch,
} from '../../src/lib/client/highlight-token-alignment';
import { segmentWords } from '../../src/lib/shared/language';
import type { TTSSentenceAlignment } from '../../src/types/tts';
const alignmentWords = (
words: string[],
): TTSSentenceAlignment['words'] =>
words.map((word, index) => ({
text: word,
startSec: index,
endSec: index + 0.5,
charStart: 0,
charEnd: word.length,
}));
describe('shared viewer highlight token alignment', () => {
test('finds an exact sentence in a large document without entering fuzzy comparison', () => {
const targets = Array.from({ length: 20_000 }, (_, index) => `token-${index}`);
targets.splice(15_000, 4, 'the', 'exact', 'sentence', 'here');
expect(findBestHighlightTokenMatch(
['the', 'exact', 'sentence', 'here'],
targets,
)).toEqual({
start: 15_000,
end: 15_003,
rating: 1,
lengthDiff: 0,
});
});
test('matches a complete Japanese sentence using locale-aware token count', () => {
const sentence = 'これは日本語です。';
const patternTokens = segmentWords(sentence, 'ja').map((token) => token.text);
const tokenTexts = ['前文', ...patternTokens, '次文'];
expect(findBestHighlightTokenMatch(patternTokens, tokenTexts)).toMatchObject({
start: 1,
end: patternTokens.length,
lengthDiff: 0,
});
});
test('matches spaced Latin text without relying on whitespace tokens', () => {
expect(findBestHighlightTokenMatch(
['hello', 'world'],
['before', 'hello', 'world', 'after'],
)).toMatchObject({
start: 1,
end: 2,
lengthDiff: 0,
});
});
test('maps a Japanese timed chunk across every visible token it spans', () => {
expect(buildAlignmentTokenRanges(
alignmentWords(['これは', '日本語', 'です']),
['これ', 'は', '日本語', 'です'],
)).toEqual([
{ start: 0, end: 1 },
{ start: 2, end: 2 },
{ start: 3, end: 3 },
]);
});
test('maps visible chunks back to a larger timed Japanese token', () => {
expect(buildAlignmentTokenRanges(
alignmentWords(['これ', 'は', '日本語', 'です']),
['これは', '日本語', 'です'],
)).toEqual([
{ start: 0, end: 0 },
{ start: 0, end: 0 },
{ start: 1, end: 1 },
{ start: 2, end: 2 },
]);
});
test('maps repeated words monotonically', () => {
expect(buildAlignmentTokenRanges(
alignmentWords(['the', 'light', 'and', 'the', 'light']),
['the', 'light', 'and', 'the', 'light'],
)).toEqual([
{ start: 0, end: 0 },
{ start: 1, end: 1 },
{ start: 2, end: 2 },
{ start: 3, end: 3 },
{ start: 4, end: 4 },
]);
});
test('can leave unrelated fallback tokens unmapped for strict viewers', () => {
expect(buildAlignmentTokenRanges(
alignmentWords(['alpha', 'missing', 'gamma']),
['alpha', 'beta', 'gamma'],
{ minimumSimilarity: 0.8 },
)).toEqual([
{ start: 0, end: 0 },
null,
{ start: 2, end: 2 },
]);
});
});

View file

@ -0,0 +1,124 @@
import { describe, expect, test } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
getTtsLanguageCompatibilityWarnings,
inferKokoroLanguageFromVoice,
keepKokoroVoicesInOneLanguage,
normalizeOptionalLanguageTag,
normalizeUnicodeToken,
resolveTtsLanguage,
segmentSentences,
segmentWords,
toBaseLanguageCode,
} from '../../src/lib/shared/language';
describe('multilingual language utilities', () => {
test('keeps the multilingual document fixture readable as UTF-8', () => {
const fixture = readFileSync(resolve(process.cwd(), 'tests/files/multilingual-sample.txt'), 'utf8');
expect(fixture).toContain('これは二番目の文です。');
expect(fixture).toContain('这是第二句话。');
expect(fixture).toContain('هذه هي الجملة الثانية.');
});
test('infers Kokoro language from a single-language voice selection', () => {
expect(inferKokoroLanguageFromVoice('jf_alpha')).toBe('ja');
expect(inferKokoroLanguageFromVoice('zf_xiaobei(0.5)+zm_yunxi(0.5)')).toBe('zh-CN');
expect(inferKokoroLanguageFromVoice('ff_siwis+jf_alpha')).toBeNull();
});
test('keeps Kokoro multi-voice selections within the newly selected language', () => {
expect(keepKokoroVoicesInOneLanguage(
['af_sarah', 'bf_emma', 'jf_alpha'],
'jf_alpha',
)).toEqual(['jf_alpha']);
expect(keepKokoroVoicesInOneLanguage(
['zf_xiaobei', 'zm_yunxi', 'af_sarah'],
'zm_yunxi',
)).toEqual(['zf_xiaobei', 'zm_yunxi']);
expect(keepKokoroVoicesInOneLanguage(
['af_sarah', 'bf_emma'],
'bf_emma',
)).toEqual(['af_sarah', 'bf_emma']);
});
test('normalizes valid EPUB metadata language tags and rejects invalid metadata', () => {
expect(normalizeOptionalLanguageTag(' ja-jp ')).toBe('ja-JP');
expect(normalizeOptionalLanguageTag('fr, en')).toBe('fr');
expect(normalizeOptionalLanguageTag('not_a_language')).toBeNull();
expect(normalizeOptionalLanguageTag(undefined)).toBeNull();
});
test('warns only for provable Kokoro language compatibility problems', () => {
expect(getTtsLanguageCompatibilityWarnings({
model: 'kokoro',
voice: 'jf_alpha',
documentLanguage: 'ja',
})).toEqual([]);
expect(getTtsLanguageCompatibilityWarnings({
model: 'kokoro',
voice: 'af_sarah',
documentLanguage: 'ja',
})).toEqual([
'Selected Kokoro voice is American English, but the document is Japanese.',
]);
expect(getTtsLanguageCompatibilityWarnings({
model: 'kokoro',
voice: 'ff_siwis+jf_alpha',
documentLanguage: 'fr',
})).toEqual([]);
expect(getTtsLanguageCompatibilityWarnings({
model: 'kokoro',
voice: 'af_sarah',
documentLanguage: 'ar',
})).toEqual([
"Kokoro's built-in voice catalog does not include Arabic.",
'Selected Kokoro voice is American English, but the document is Arabic.',
]);
expect(getTtsLanguageCompatibilityWarnings({
model: 'custom-unknown-model',
voice: 'af_sarah',
documentLanguage: 'ja',
})).toEqual([]);
});
test('prefers an explicit language and resolves regional tags for Whisper', () => {
expect(resolveTtsLanguage({ configuredLanguage: 'pt-BR', voice: 'jf_alpha' })).toBe('pt-BR');
expect(resolveTtsLanguage({ configuredLanguage: 'auto', voice: 'jf_alpha' })).toBe('ja');
expect(toBaseLanguageCode('zh-CN')).toBe('zh');
});
test('normalizes Unicode tokens without dropping non-Latin scripts', () => {
expect(normalizeUnicodeToken('École!')).toBe('école');
expect(normalizeUnicodeToken('日本語。')).toBe('日本語');
expect(normalizeUnicodeToken('العربية؟')).toBe('العربية');
expect(normalizeUnicodeToken('हिन्दी।')).toBe('हिन्दी');
});
test('segments CJK sentences and no-space language words', () => {
expect(segmentSentences('これは最初の文です。これは二番目の文です。', 'ja')).toEqual([
'これは最初の文です。',
'これは二番目の文です。',
]);
const chineseWords = segmentWords('这是第一句话。', 'zh').map((token) => token.text);
expect(chineseWords.length).toBeGreaterThan(1);
expect(chineseWords.join('')).toBe('这是第一句话');
});
test('preserves source offsets for Thai word segmentation', () => {
const text = 'นี่คือประโยคแรก';
const words = segmentWords(text, 'th');
expect(words.length).toBeGreaterThan(1);
for (const word of words) {
expect(text.slice(word.start, word.end)).toBe(word.text);
}
});
});

View file

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

View file

@ -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('<InlineButton');
expect(source, relativePath).not.toMatch(/<button\b/);
}
});
test('shared primitives own standard form-control chrome', () => {
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');
});
});

View file

@ -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');
});
});

View file

@ -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('<Select');
expect(source, relativePath).not.toContain('<Listbox');
expect(source, relativePath).not.toContain('<SharedListboxButton');
expect(source, relativePath).not.toContain('<SharedListboxOptions');
expect(source, relativePath).not.toContain('<SharedListboxOption');
}
});
test('the shared Select owns standard dropdown chrome', () => {
const source = readFileSync(resolve(process.cwd(), 'src/components/ui/select.tsx'), 'utf8');
expect(source).toContain('ChevronUpDownIcon');
expect(source).toContain('CheckIcon');
expect(source).toContain('<Transition');
});
});

View file

@ -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();
});
});

View file

@ -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', () => {

View file

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