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:
commit
3d1ef1fd41
69 changed files with 2101 additions and 1191 deletions
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
# 📄🔊 OpenReader
|
# 📄🔊 OpenReader
|
||||||
|
|
||||||
OpenReader is an open source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with synchronized read-along playback.
|
OpenReader is an open-source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with multilingual, synchronized read-along playback.
|
||||||
|
|
||||||
> Previously named **OpenReader-WebUI**.
|
> Previously named **OpenReader-WebUI**.
|
||||||
|
|
||||||
|
|
@ -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).
|
- ⏱️ **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.
|
- ⚡ **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).
|
- 🎯 **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.
|
- 🎧 **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.
|
- 🗂️ **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.
|
- 🐳 **Self-host friendly** — Docker (amd64/arm64), built-in auth/session support, and automatic startup migrations.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ title: Introduction
|
||||||
slug: /
|
slug: /
|
||||||
---
|
---
|
||||||
|
|
||||||
OpenReader is an open source text-to-speech document reader built with Next.js. It provides a read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**.
|
OpenReader is an open-source text-to-speech document reader built with Next.js. It provides a multilingual read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**.
|
||||||
|
|
||||||
> Previously named **OpenReader-WebUI**.
|
> Previously named **OpenReader-WebUI**.
|
||||||
|
|
||||||
|
|
@ -21,6 +21,9 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c
|
||||||
- 🎯 **Multi-Provider TTS Support**
|
- 🎯 **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
|
- 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)
|
- 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
|
- 🎧 **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
|
- 🗂️ **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
|
- 🔐 **Auth and User Isolation** — auth is required in v4+, with optional anonymous auth sessions for guest flows
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
|
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
|
||||||
|
import { useDocumentLanguage } from '@/hooks/useDocumentLanguage';
|
||||||
import { ButtonLink } from '@/components/ui';
|
import { ButtonLink } from '@/components/ui';
|
||||||
import { useEpubDocument } from './useEpubDocument';
|
import { useEpubDocument } from './useEpubDocument';
|
||||||
|
|
||||||
|
|
@ -36,8 +37,10 @@ export default function EPUBPage() {
|
||||||
createFullAudioBook: createEPUBAudioBook,
|
createFullAudioBook: createEPUBAudioBook,
|
||||||
regenerateChapter: regenerateEPUBChapter,
|
regenerateChapter: regenerateEPUBChapter,
|
||||||
bookRef,
|
bookRef,
|
||||||
|
metadataLanguage,
|
||||||
} = epubState;
|
} = epubState;
|
||||||
const { stop } = useTTS();
|
const { stop, setDocumentLanguage } = useTTS();
|
||||||
|
const { language, updateLanguage } = useDocumentLanguage(routeDocumentId);
|
||||||
const { isAtLimit } = useAuthRateLimit();
|
const { isAtLimit } = useAuthRateLimit();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
@ -106,6 +109,10 @@ export default function EPUBPage() {
|
||||||
|
|
||||||
useUnmountCleanupRef(clearCurrDoc);
|
useUnmountCleanupRef(clearCurrDoc);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDocumentLanguage(language === 'auto' ? metadataLanguage ?? 'auto' : language);
|
||||||
|
}, [language, metadataLanguage, setDocumentLanguage]);
|
||||||
|
|
||||||
// Compute available height = viewport - (header height + tts bar height)
|
// Compute available height = viewport - (header height + tts bar height)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const compute = () => {
|
const compute = () => {
|
||||||
|
|
@ -249,6 +256,11 @@ export default function EPUBPage() {
|
||||||
epub
|
epub
|
||||||
isOpen={activeSidebar === 'settings'}
|
isOpen={activeSidebar === 'settings'}
|
||||||
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
|
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
|
||||||
|
language={language}
|
||||||
|
detectedLanguage={metadataLanguage}
|
||||||
|
onLanguageChange={(nextLanguage) => {
|
||||||
|
void updateLanguage(nextLanguage);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<SegmentsSidebar
|
<SegmentsSidebar
|
||||||
isOpen={activeSidebar === 'segments'}
|
isOpen={activeSidebar === 'segments'}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import type {
|
||||||
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
||||||
import { isStableEpubLocator } from '@/types/client';
|
import { isStableEpubLocator } from '@/types/client';
|
||||||
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
||||||
|
import { normalizeOptionalLanguageTag } from '@/lib/shared/language';
|
||||||
|
|
||||||
export interface EpubDocumentState {
|
export interface EpubDocumentState {
|
||||||
currDocData: ArrayBuffer | undefined;
|
currDocData: ArrayBuffer | undefined;
|
||||||
|
|
@ -51,6 +52,7 @@ export interface EpubDocumentState {
|
||||||
currDocPages: number | undefined;
|
currDocPages: number | undefined;
|
||||||
currDocPage: number | string;
|
currDocPage: number | string;
|
||||||
currDocText: string | undefined;
|
currDocText: string | undefined;
|
||||||
|
metadataLanguage: string | null;
|
||||||
isPlaybackReady: boolean;
|
isPlaybackReady: boolean;
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
|
|
@ -93,7 +95,16 @@ export interface EpubDocumentState {
|
||||||
* Route-local EPUB reader hook.
|
* Route-local EPUB reader hook.
|
||||||
*/
|
*/
|
||||||
export function useEpubDocument(documentId?: string): EpubDocumentState {
|
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
|
// Configuration context to get TTS settings
|
||||||
const {
|
const {
|
||||||
apiKey,
|
apiKey,
|
||||||
|
|
@ -107,6 +118,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
|
const [metadataLanguage, setMetadataLanguage] = useState<string | null>(null);
|
||||||
const [isPlaybackReady, setIsPlaybackReady] = useState(false);
|
const [isPlaybackReady, setIsPlaybackReady] = useState(false);
|
||||||
// Mirror state into a ref so resolveEpubLocator (registered once with
|
// Mirror state into a ref so resolveEpubLocator (registered once with
|
||||||
// TTSContext via a stable callback) can always read the latest page text
|
// TTSContext via a stable callback) can always read the latest page text
|
||||||
|
|
@ -145,6 +157,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
currentWordHighlightCfiRef: currentWordHighlightCfi,
|
currentWordHighlightCfiRef: currentWordHighlightCfi,
|
||||||
renderedTextMapsRef,
|
renderedTextMapsRef,
|
||||||
wordHighlightMapCacheRef,
|
wordHighlightMapCacheRef,
|
||||||
|
language: resolvedLanguage,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
|
|
@ -162,6 +175,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
setCurrDocData(undefined);
|
setCurrDocData(undefined);
|
||||||
setCurrDocName(undefined);
|
setCurrDocName(undefined);
|
||||||
setCurrDocText(undefined);
|
setCurrDocText(undefined);
|
||||||
|
setMetadataLanguage(null);
|
||||||
setIsPlaybackReady(false);
|
setIsPlaybackReady(false);
|
||||||
setCurrDocPages(undefined);
|
setCurrDocPages(undefined);
|
||||||
isEPUBSetOnce.current = false;
|
isEPUBSetOnce.current = false;
|
||||||
|
|
@ -183,6 +197,9 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsPlaybackReady(false);
|
setIsPlaybackReady(false);
|
||||||
|
setMetadataLanguage(null);
|
||||||
|
bookRef.current = null;
|
||||||
|
renditionRef.current = undefined;
|
||||||
const meta = await getDocumentMetadata(id);
|
const meta = await getDocumentMetadata(id);
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
clearCurrDoc();
|
clearCurrDoc();
|
||||||
|
|
@ -370,8 +387,19 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
});
|
});
|
||||||
|
|
||||||
const setRendition = useCallback((rendition: Rendition) => {
|
const setRendition = useCallback((rendition: Rendition) => {
|
||||||
bookRef.current = rendition.book;
|
const book = rendition.book;
|
||||||
|
bookRef.current = book;
|
||||||
renditionRef.current = rendition;
|
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({
|
const handleLocationChanged = useEPUBLocationController({
|
||||||
|
|
@ -396,6 +424,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
currDocPages,
|
currDocPages,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocText,
|
currDocText,
|
||||||
|
metadataLanguage,
|
||||||
isPlaybackReady,
|
isPlaybackReady,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
|
|
@ -422,6 +451,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
currDocPages,
|
currDocPages,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocText,
|
currDocText,
|
||||||
|
metadataLanguage,
|
||||||
isPlaybackReady,
|
isPlaybackReady,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
|
import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef';
|
||||||
|
import { useDocumentLanguage } from '@/hooks/useDocumentLanguage';
|
||||||
import { ButtonLink } from '@/components/ui';
|
import { ButtonLink } from '@/components/ui';
|
||||||
import type { TTSAudiobookChapter } from '@/types/tts';
|
import type { TTSAudiobookChapter } from '@/types/tts';
|
||||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
|
|
@ -26,6 +27,7 @@ export default function HTMLPage() {
|
||||||
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const routeDocumentId = typeof id === 'string' ? id : undefined;
|
||||||
const htmlState = useHtmlDocument();
|
const htmlState = useHtmlDocument();
|
||||||
const {
|
const {
|
||||||
setCurrentDocument,
|
setCurrentDocument,
|
||||||
|
|
@ -38,7 +40,8 @@ export default function HTMLPage() {
|
||||||
createFullAudioBook,
|
createFullAudioBook,
|
||||||
regenerateChapter,
|
regenerateChapter,
|
||||||
} = htmlState;
|
} = htmlState;
|
||||||
const { stop } = useTTS();
|
const { stop, setDocumentLanguage } = useTTS();
|
||||||
|
const { language, updateLanguage } = useDocumentLanguage(routeDocumentId);
|
||||||
const { isAtLimit } = useAuthRateLimit();
|
const { isAtLimit } = useAuthRateLimit();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
@ -106,6 +109,10 @@ export default function HTMLPage() {
|
||||||
|
|
||||||
useUnmountCleanupRef(clearCurrDoc);
|
useUnmountCleanupRef(clearCurrDoc);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDocumentLanguage(language);
|
||||||
|
}, [language, setDocumentLanguage]);
|
||||||
|
|
||||||
// Compute available height = viewport - (header height + tts bar height)
|
// Compute available height = viewport - (header height + tts bar height)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const compute = () => {
|
const compute = () => {
|
||||||
|
|
@ -240,6 +247,10 @@ export default function HTMLPage() {
|
||||||
html
|
html
|
||||||
isOpen={activeSidebar === 'settings'}
|
isOpen={activeSidebar === 'settings'}
|
||||||
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
|
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
|
||||||
|
language={language}
|
||||||
|
onLanguageChange={(nextLanguage) => {
|
||||||
|
void updateLanguage(nextLanguage);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<SegmentsSidebar
|
<SegmentsSidebar
|
||||||
isOpen={activeSidebar === 'segments'}
|
isOpen={activeSidebar === 'segments'}
|
||||||
|
|
|
||||||
|
|
@ -493,6 +493,14 @@ export default function PDFViewerPage() {
|
||||||
<DocumentSettings
|
<DocumentSettings
|
||||||
isOpen={activeSidebar === 'settings'}
|
isOpen={activeSidebar === 'settings'}
|
||||||
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
|
setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))}
|
||||||
|
language={documentSettings.language ?? 'auto'}
|
||||||
|
onLanguageChange={(language) => {
|
||||||
|
void updateDocumentSettings({
|
||||||
|
...documentSettings,
|
||||||
|
schemaVersion: 1,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
}}
|
||||||
pdf={{
|
pdf={{
|
||||||
parseStatus,
|
parseStatus,
|
||||||
parsedOverlayEnabled,
|
parsedOverlayEnabled,
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ export interface PdfDocumentState {
|
||||||
parsedDocument?: ParsedPdfDocument | null;
|
parsedDocument?: ParsedPdfDocument | null;
|
||||||
locator?: TTSSegmentLocator | null;
|
locator?: TTSSegmentLocator | null;
|
||||||
useBlockGeometryOnly?: boolean;
|
useBlockGeometryOnly?: boolean;
|
||||||
|
language?: string;
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
clearHighlights: () => void;
|
clearHighlights: () => void;
|
||||||
|
|
@ -134,6 +135,7 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
currDocPages,
|
currDocPages,
|
||||||
setCurrDocPages,
|
setCurrDocPages,
|
||||||
setIsEPUB,
|
setIsEPUB,
|
||||||
|
setDocumentLanguage,
|
||||||
registerVisualPageChangeHandler,
|
registerVisualPageChangeHandler,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
const {
|
const {
|
||||||
|
|
@ -156,6 +158,10 @@ export function usePdfDocument(): PdfDocumentState {
|
||||||
const [parseProgress, setParseProgress] = useState<PdfParseProgress | null>(null);
|
const [parseProgress, setParseProgress] = useState<PdfParseProgress | null>(null);
|
||||||
const [, setActiveParseOpId] = useState<string | null>(null);
|
const [, setActiveParseOpId] = useState<string | null>(null);
|
||||||
const [documentSettings, setDocumentSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
|
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 [parsedOverlayEnabled, setParsedOverlayEnabled] = useState(false);
|
||||||
const [isAudioCombining] = useState(false);
|
const [isAudioCombining] = useState(false);
|
||||||
const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({
|
const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||||
import { GithubIcon } from '@/components/icons/Icons';
|
import { GithubIcon } from '@/components/icons/Icons';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
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 }) {
|
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -169,11 +169,9 @@ function SignInContent() {
|
||||||
|
|
||||||
{/* Remember Me */}
|
{/* Remember Me */}
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
checked={rememberMe}
|
checked={rememberMe}
|
||||||
onChange={(e) => setRememberMe(e.target.checked)}
|
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>
|
<span className="text-sm text-foreground">Remember me</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
@ -238,12 +236,9 @@ function SignInContent() {
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-soft">
|
<p className="text-xs text-soft">
|
||||||
By signing in, you agree to our{' '}
|
By signing in, you agree to our{' '}
|
||||||
<button
|
<InlineButton onClick={() => showPrivacyModal()}>
|
||||||
onClick={() => showPrivacyModal()}
|
|
||||||
className="underline hover:text-foreground"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</button>
|
</InlineButton>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
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';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
|
|
@ -241,12 +241,9 @@ export default function SignUpPage() {
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-soft">
|
<p className="text-xs text-soft">
|
||||||
By creating an account, you agree to our{' '}
|
By creating an account, you agree to our{' '}
|
||||||
<button
|
<InlineButton onClick={() => showPrivacyModal()}>
|
||||||
onClick={() => showPrivacyModal()}
|
|
||||||
className="underline hover:text-foreground"
|
|
||||||
>
|
|
||||||
Privacy Policy
|
Privacy Policy
|
||||||
</button>
|
</InlineButton>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Surface>
|
</Surface>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import { ButtonAnchor, ButtonLink } from '@/components/ui';
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Open Source Read-Along Workspace',
|
title: 'Open Source Read-Along Workspace',
|
||||||
description:
|
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:
|
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: {
|
alternates: {
|
||||||
canonical: '/',
|
canonical: '/',
|
||||||
},
|
},
|
||||||
|
|
@ -19,7 +19,7 @@ export const metadata: Metadata = {
|
||||||
siteName: 'OpenReader',
|
siteName: 'OpenReader',
|
||||||
title: 'OpenReader | Read documents with synchronized audio',
|
title: 'OpenReader | Read documents with synchronized audio',
|
||||||
description:
|
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: [
|
images: [
|
||||||
{
|
{
|
||||||
url: '/web-app-manifest-512x512.png',
|
url: '/web-app-manifest-512x512.png',
|
||||||
|
|
@ -91,7 +91,7 @@ export default async function LandingPage() {
|
||||||
<p className="public-hero-copy">
|
<p className="public-hero-copy">
|
||||||
OpenReader turns EPUB, PDF, TXT, Markdown, and DOCX into a
|
OpenReader turns EPUB, PDF, TXT, Markdown, and DOCX into a
|
||||||
synchronized read-along surface, reading your original file in a
|
synchronized read-along surface, reading your original file in a
|
||||||
native viewer with genuine text-to-speech, word-level
|
native viewer with multilingual text-to-speech, language-aware
|
||||||
highlighting, and audiobook export. It’s open source, and
|
highlighting, and audiobook export. It’s open source, and
|
||||||
entirely yours to self-host.
|
entirely yours to self-host.
|
||||||
</p>
|
</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="b" />
|
||||||
<span className="public-reader-dot" data-tone="c" />
|
<span className="public-reader-dot" data-tone="c" />
|
||||||
<span className="public-reader-file">wizard-of-oz.epub</span>
|
<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>
|
||||||
|
|
||||||
<div className="public-reader-body">
|
<div className="public-reader-body">
|
||||||
|
|
@ -203,7 +203,8 @@ export default async function LandingPage() {
|
||||||
<p>
|
<p>
|
||||||
Choose a provider and model: hosted OpenAI, Replicate, or
|
Choose a provider and model: hosted OpenAI, Replicate, or
|
||||||
DeepInfra, or your own self-hosted Kokoro, KittenTTS, or Orpheus
|
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>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
<li className="public-step">
|
<li className="public-step">
|
||||||
|
|
@ -251,11 +252,11 @@ export default async function LandingPage() {
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article className="public-feature">
|
<article className="public-feature">
|
||||||
<span className="public-feature-kicker">Voices</span>
|
<span className="public-feature-kicker">Languages</span>
|
||||||
<h3>Multi-provider TTS</h3>
|
<h3>Multilingual support</h3>
|
||||||
<p>
|
<p>
|
||||||
Mix cloud APIs with OpenAI-compatible local servers. Bring your
|
Choose a document language for language-aware narration,
|
||||||
own keys and endpoints, with no lock-in to a single vendor.
|
highlighting, and compatible voice selection.
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -432,7 +432,8 @@ export async function POST(request: NextRequest) {
|
||||||
normalizedExistingSettings.nativeSpeed !== normalizedIncomingSettings.nativeSpeed ||
|
normalizedExistingSettings.nativeSpeed !== normalizedIncomingSettings.nativeSpeed ||
|
||||||
normalizedExistingSettings.postSpeed !== normalizedIncomingSettings.postSpeed ||
|
normalizedExistingSettings.postSpeed !== normalizedIncomingSettings.postSpeed ||
|
||||||
normalizedExistingSettings.format !== normalizedIncomingSettings.format ||
|
normalizedExistingSettings.format !== normalizedIncomingSettings.format ||
|
||||||
(normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || '');
|
(normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || '') ||
|
||||||
|
(normalizedExistingSettings.language || '') !== (normalizedIncomingSettings.language || '');
|
||||||
if (mismatch) {
|
if (mismatch) {
|
||||||
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: normalizedExistingSettings }, { status: 409 });
|
return NextResponse.json({ error: 'Audiobook settings mismatch', settings: normalizedExistingSettings }, { status: 409 });
|
||||||
}
|
}
|
||||||
|
|
@ -596,6 +597,7 @@ export async function POST(request: NextRequest) {
|
||||||
format: 'mp3',
|
format: 'mp3',
|
||||||
model,
|
model,
|
||||||
instructions,
|
instructions,
|
||||||
|
language: mergedSettings?.language,
|
||||||
provider,
|
provider,
|
||||||
apiKey: openApiKey,
|
apiKey: openApiKey,
|
||||||
baseUrl: openApiBaseUrl,
|
baseUrl: openApiBaseUrl,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tt
|
||||||
import { userWhisperAlignJob } from '@/lib/server/jobs/user-whisper-align-job';
|
import { userWhisperAlignJob } from '@/lib/server/jobs/user-whisper-align-job';
|
||||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||||
import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy';
|
import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy';
|
||||||
|
import { normalizeLanguageTag } from '@/lib/shared/language';
|
||||||
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
|
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
|
||||||
import { createRequestLogger, errorToLog } from '@/lib/server/logger';
|
import { createRequestLogger, errorToLog } from '@/lib/server/logger';
|
||||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
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 (typeof rec.voice !== 'string') return null;
|
||||||
if (!Number.isFinite(Number(rec.nativeSpeed))) return null;
|
if (!Number.isFinite(Number(rec.nativeSpeed))) return null;
|
||||||
if (rec.ttsInstructions !== undefined && typeof rec.ttsInstructions !== 'string') 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 {
|
return {
|
||||||
providerRef: rec.providerRef,
|
providerRef: rec.providerRef,
|
||||||
|
|
@ -70,6 +73,7 @@ function parseSettings(value: unknown): TTSSegmentSettings | null {
|
||||||
voice: rec.voice,
|
voice: rec.voice,
|
||||||
nativeSpeed: Number(rec.nativeSpeed),
|
nativeSpeed: Number(rec.nativeSpeed),
|
||||||
...(typeof rec.ttsInstructions === 'string' ? { ttsInstructions: rec.ttsInstructions } : {}),
|
...(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({
|
alignment = await userWhisperAlignJob({
|
||||||
audioObjectKey: existing.audioKey,
|
audioObjectKey: existing.audioKey,
|
||||||
text: segment.text,
|
text: segment.text,
|
||||||
|
lang: effectiveSettings.language,
|
||||||
sentenceIndex: segment.original.segmentIndex,
|
sentenceIndex: segment.original.segmentIndex,
|
||||||
});
|
});
|
||||||
stageTimings.selfHealAlignMs = Date.now() - alignStartedAt;
|
stageTimings.selfHealAlignMs = Date.now() - alignStartedAt;
|
||||||
|
|
@ -637,6 +642,7 @@ export async function POST(request: NextRequest) {
|
||||||
format: 'mp3',
|
format: 'mp3',
|
||||||
model: effectiveSettings.ttsModel,
|
model: effectiveSettings.ttsModel,
|
||||||
instructions: effectiveSettings.ttsInstructions,
|
instructions: effectiveSettings.ttsInstructions,
|
||||||
|
language: effectiveSettings.language,
|
||||||
provider: requestCreds.provider,
|
provider: requestCreds.provider,
|
||||||
apiKey: requestCreds.apiKey || 'none',
|
apiKey: requestCreds.apiKey || 'none',
|
||||||
baseUrl: requestCreds.baseUrl,
|
baseUrl: requestCreds.baseUrl,
|
||||||
|
|
@ -674,6 +680,7 @@ export async function POST(request: NextRequest) {
|
||||||
alignment = await userWhisperAlignJob({
|
alignment = await userWhisperAlignJob({
|
||||||
audioObjectKey: audioKey,
|
audioObjectKey: audioKey,
|
||||||
text: segment.text,
|
text: segment.text,
|
||||||
|
lang: effectiveSettings.language,
|
||||||
sentenceIndex: segment.original.segmentIndex,
|
sentenceIndex: segment.original.segmentIndex,
|
||||||
});
|
});
|
||||||
stageTimings.whisperAlignMs = Date.now() - alignStartedAt;
|
stageTimings.whisperAlignMs = Date.now() - alignStartedAt;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import type {
|
||||||
TTSSegmentsManifestResponse,
|
TTSSegmentsManifestResponse,
|
||||||
} from '@/types/client';
|
} from '@/types/client';
|
||||||
import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
|
import { normalizeLanguageTag } from '@/lib/shared/language';
|
||||||
import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
|
import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy';
|
||||||
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
|
import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls';
|
||||||
import { createRequestLogger } from '@/lib/server/logger';
|
import { createRequestLogger } from '@/lib/server/logger';
|
||||||
|
|
@ -71,9 +72,10 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null {
|
||||||
const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
|
const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1;
|
||||||
const instructionsSource = rec.ttsInstructions ?? rec.instructions;
|
const instructionsSource = rec.ttsInstructions ?? rec.instructions;
|
||||||
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : '';
|
const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : '';
|
||||||
|
const language = typeof rec.language === 'string' ? normalizeLanguageTag(rec.language, 'en') : 'en';
|
||||||
|
|
||||||
if (!providerRef || !providerType || !ttsModel || !voice) return null;
|
if (!providerRef || !providerType || !ttsModel || !voice) return null;
|
||||||
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions };
|
return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions, language };
|
||||||
}
|
}
|
||||||
|
|
||||||
function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {
|
function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,19 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
||||||
import { Transition, Listbox, Menu, MenuButton } from '@headlessui/react';
|
|
||||||
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
import { useTimeEstimation } from '@/hooks/useTimeEstimation';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
import { ProgressCard } from '@/components/ProgressCard';
|
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 { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
||||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
|
import { getTtsLanguageCompatibilityWarnings, resolveTtsLanguage } from '@/lib/shared/language';
|
||||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
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 {
|
import {
|
||||||
getAudiobookStatus,
|
getAudiobookStatus,
|
||||||
deleteAudiobookChapter,
|
deleteAudiobookChapter,
|
||||||
|
|
@ -50,7 +50,7 @@ export function AudiobookExportModal({
|
||||||
onRegenerateChapter
|
onRegenerateChapter
|
||||||
}: AudiobookExportModalProps) {
|
}: AudiobookExportModalProps) {
|
||||||
const { isLoading, isDBReady, providerRef, providerType, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig();
|
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 { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
|
||||||
const [isGenerating, setIsGenerating] = useState(false);
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
const [chapters, setChapters] = useState<TTSAudiobookChapter[]>([]);
|
const [chapters, setChapters] = useState<TTSAudiobookChapter[]>([]);
|
||||||
|
|
@ -125,8 +125,17 @@ export function AudiobookExportModal({
|
||||||
postSpeed,
|
postSpeed,
|
||||||
format,
|
format,
|
||||||
ttsInstructions: providerModelPolicy.supportsInstructions ? ttsInstructions : undefined,
|
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) => {
|
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
||||||
if (soft) {
|
if (soft) {
|
||||||
|
|
@ -555,56 +564,35 @@ export function AudiobookExportModal({
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Format</label>
|
<label className="text-[11px] uppercase tracking-wider font-medium text-soft">Format</label>
|
||||||
{chapters.length === 0 ? (
|
{chapters.length === 0 ? (
|
||||||
<Listbox
|
<Select
|
||||||
value={format}
|
value={format}
|
||||||
onChange={(newFormat) => setFormat(newFormat)}
|
onChange={(newFormat) => setFormat(newFormat)}
|
||||||
|
options={['m4b', 'mp3'] as const}
|
||||||
disabled={chapters.length > 0 || settingsLocked}
|
disabled={chapters.length > 0 || settingsLocked}
|
||||||
>
|
renderValue={(option) => (
|
||||||
<div className="relative">
|
<span className="text-sm font-medium">{option.toUpperCase()}</span>
|
||||||
<SharedListboxButton className="bg-surface">
|
)}
|
||||||
<span className="block truncate text-sm font-medium">{format.toUpperCase()}</span>
|
renderOption={(option, { selected }) => (
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
<span className={`block truncate text-sm ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
<ChevronUpDownIcon className="h-4 w-4 text-soft" />
|
{option.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</SharedListboxButton>
|
)}
|
||||||
<Transition
|
buttonClassName="bg-surface"
|
||||||
as={Fragment}
|
chevronClassName="h-4 w-4 text-soft"
|
||||||
leave="transition ease-standard duration-fast"
|
optionInset="none"
|
||||||
leaveFrom="opacity-100"
|
optionItemClassName="py-2"
|
||||||
leaveTo="opacity-0"
|
showCheckmark={false}
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div>
|
<div className="text-sm font-medium text-foreground py-1.5 pl-3">{format.toUpperCase()}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{languageWarnings.map((warning) => (
|
||||||
|
<p key={warning} className="text-xs text-warning">
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Speed controls */}
|
{/* Speed controls */}
|
||||||
<Card className="p-3 space-y-3">
|
<Card className="p-3 space-y-3">
|
||||||
|
|
@ -773,23 +761,15 @@ export function AudiobookExportModal({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
|
{((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && (
|
||||||
<Menu as="div" className="relative inline-block text-left">
|
<MenuRoot as="div" className="relative inline-block text-left">
|
||||||
<MenuButton
|
<MenuTrigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Chapter actions"
|
title="Chapter actions"
|
||||||
>
|
>
|
||||||
<DotsVerticalIcon className="h-5 w-5" />
|
<DotsVerticalIcon className="h-5 w-5" />
|
||||||
</MenuButton>
|
</MenuTrigger>
|
||||||
<Transition
|
<MenuTransition>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<MenuItemsSurface
|
<MenuItemsSurface
|
||||||
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
|
anchor={{ to: 'bottom end', gap: '8px', padding: '12px' }}
|
||||||
portal
|
portal
|
||||||
|
|
@ -833,8 +813,8 @@ export function AudiobookExportModal({
|
||||||
)}
|
)}
|
||||||
</MenuItemsSurface>
|
</MenuItemsSurface>
|
||||||
{/* end of menu items */}
|
{/* end of menu items */}
|
||||||
</Transition>
|
</MenuTransition>
|
||||||
</Menu>
|
</MenuRoot>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useCallback } from 'react';
|
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { Popover, PopoverButton } from '@headlessui/react';
|
|
||||||
import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
|
import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext';
|
||||||
import { PaletteIcon } from '@/components/icons/Icons';
|
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
|
* 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];
|
const swatches = ROLE_SWATCHES[field];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative flex items-center">
|
<PopoverRoot className="relative flex items-center">
|
||||||
<PopoverButton as={IconButton} size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
|
<PopoverIconTrigger size="sm" className="group rounded-full p-0" aria-label={`Pick ${label} color`}>
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-full border-2 transition duration-fast group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
|
className="w-6 h-6 rounded-full border-2 transition duration-fast group-focus-visible:ring-2 group-focus-visible:ring-offset-1"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -87,7 +86,7 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
||||||
borderColor: isLightColor(value) ? '#00000022' : '#ffffff22',
|
borderColor: isLightColor(value) ? '#00000022' : '#ffffff22',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</PopoverButton>
|
</PopoverIconTrigger>
|
||||||
|
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
anchor="bottom start"
|
anchor="bottom start"
|
||||||
|
|
@ -171,6 +170,6 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps)
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</Popover>
|
</PopoverRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { updateAppConfig } from '@/lib/client/dexie';
|
import { updateAppConfig } from '@/lib/client/dexie';
|
||||||
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
|
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
|
||||||
|
|
||||||
interface PrivacyModalProps {
|
interface PrivacyModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -78,13 +78,11 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
|
||||||
<div className="mt-6 space-y-4">
|
<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 items-start gap-3 rounded-lg border border-line p-3 bg-surface-sunken">
|
||||||
<div className="flex h-6 items-center">
|
<div className="flex h-6 items-center">
|
||||||
<input
|
<Checkbox
|
||||||
data-testid="privacy-agree-checkbox"
|
data-testid="privacy-agree-checkbox"
|
||||||
id="privacy-agree"
|
id="privacy-agree"
|
||||||
type="checkbox"
|
|
||||||
checked={agreed}
|
checked={agreed}
|
||||||
onChange={(e) => setAgreed(e.target.checked)}
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm leading-6">
|
<div className="text-sm leading-6">
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
|
import { Fragment, useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
|
||||||
Transition,
|
|
||||||
Listbox,
|
|
||||||
} from '@headlessui/react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
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 { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||||
import { ProgressPopup } from '@/components/ProgressPopup';
|
import { ProgressPopup } from '@/components/ProgressPopup';
|
||||||
|
|
@ -50,12 +46,10 @@ import {
|
||||||
ChoiceTile,
|
ChoiceTile,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
|
Textarea,
|
||||||
ModalFrame,
|
ModalFrame,
|
||||||
ModalTitle,
|
ModalTitle,
|
||||||
inputClass,
|
Select,
|
||||||
SharedListboxButton,
|
|
||||||
SharedListboxOption,
|
|
||||||
SharedListboxOptions,
|
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
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.
|
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<Listbox
|
<Select
|
||||||
value={selectedProviderOption!}
|
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) => {
|
onChange={(provider) => {
|
||||||
const defaults = resolveProviderDefaults({
|
const defaults = resolveProviderDefaults({
|
||||||
providerRef: provider.id,
|
providerRef: provider.id,
|
||||||
|
|
@ -675,46 +677,7 @@ export function SettingsModal({
|
||||||
}
|
}
|
||||||
setCustomModelInput('');
|
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>
|
</div>
|
||||||
{restrictUserApiKeys && (
|
{restrictUserApiKeys && (
|
||||||
|
|
@ -734,7 +697,6 @@ export function SettingsModal({
|
||||||
value={localBaseUrl}
|
value={localBaseUrl}
|
||||||
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
|
||||||
placeholder="Using environment variable"
|
placeholder="Using environment variable"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -750,7 +712,6 @@ export function SettingsModal({
|
||||||
value={localApiKey}
|
value={localApiKey}
|
||||||
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
onChange={(e) => handleInputChange('apiKey', e.target.value)}
|
||||||
placeholder="Using environment variable"
|
placeholder="Using environment variable"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -768,8 +729,30 @@ export function SettingsModal({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Listbox
|
<Select
|
||||||
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
|
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) => {
|
onChange={(model) => {
|
||||||
if (model.id === 'custom') {
|
if (model.id === 'custom') {
|
||||||
setModelValue(customModelInput);
|
setModelValue(customModelInput);
|
||||||
|
|
@ -778,62 +761,7 @@ export function SettingsModal({
|
||||||
setCustomModelInput('');
|
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' && (
|
{supportsCustom && selectedModelId === 'custom' && (
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -844,7 +772,6 @@ export function SettingsModal({
|
||||||
setModelValue(e.target.value);
|
setModelValue(e.target.value);
|
||||||
}}
|
}}
|
||||||
placeholder="Enter custom model name"
|
placeholder="Enter custom model name"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -853,11 +780,11 @@ export function SettingsModal({
|
||||||
{providerModelPolicy.supportsInstructions && (
|
{providerModelPolicy.supportsInstructions && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className={fieldLabelClass}>TTS Instructions</label>
|
<label className={fieldLabelClass}>TTS Instructions</label>
|
||||||
<textarea
|
<Textarea
|
||||||
value={localTTSInstructions}
|
value={localTTSInstructions}
|
||||||
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
||||||
placeholder="Enter instructions for the TTS model"
|
placeholder="Enter instructions for the TTS model"
|
||||||
className={`${inputClass} h-24 resize-none`}
|
className="h-24 resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,15 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Listbox, Transition } from '@headlessui/react';
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
|
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Section,
|
Section,
|
||||||
ToggleRow,
|
ToggleRow,
|
||||||
inputClass,
|
Select,
|
||||||
SharedListboxButton,
|
|
||||||
SharedListboxOption,
|
|
||||||
SharedListboxOptions,
|
|
||||||
Button,
|
Button,
|
||||||
|
Input,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
|
||||||
|
|
@ -204,42 +200,19 @@ export function AdminFeaturesPanel() {
|
||||||
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
|
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
|
||||||
</div>
|
</div>
|
||||||
{providerOptions.length > 0 ? (
|
{providerOptions.length > 0 ? (
|
||||||
<Listbox value={selectedProviderOption} onChange={handleProviderChange}>
|
<Select
|
||||||
<SharedListboxButton>
|
value={selectedProviderOption}
|
||||||
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span>
|
onChange={handleProviderChange}
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
options={providerOptions}
|
||||||
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
|
getOptionKey={(option) => option.id}
|
||||||
|
renderValue={(option) => option.name}
|
||||||
|
renderOption={(option, { selected }) => (
|
||||||
|
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||||
|
{option.name}
|
||||||
</span>
|
</span>
|
||||||
</SharedListboxButton>
|
)}
|
||||||
<Transition
|
chevronClassName="h-4 w-4 text-muted"
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="px-0.5 py-2 text-sm text-muted">
|
<div className="px-0.5 py-2 text-sm text-muted">
|
||||||
No shared providers yet. Add one first.
|
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>
|
<label className="text-xs font-medium text-foreground">Anonymous per-user daily limit</label>
|
||||||
{renderSource('ttsDailyLimitAnonymous')}
|
{renderSource('ttsDailyLimitAnonymous')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsDailyLimitAnonymous ?? '')}
|
value={String(draft.ttsDailyLimitAnonymous ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
|
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>
|
<label className="text-xs font-medium text-foreground">Authenticated per-user daily limit</label>
|
||||||
{renderSource('ttsDailyLimitAuthenticated')}
|
{renderSource('ttsDailyLimitAuthenticated')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
|
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
|
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>
|
<label className="text-xs font-medium text-foreground">Anonymous IP daily backstop</label>
|
||||||
{renderSource('ttsIpDailyLimitAnonymous')}
|
{renderSource('ttsIpDailyLimitAnonymous')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
|
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
|
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>
|
<label className="text-xs font-medium text-foreground">Authenticated IP daily backstop</label>
|
||||||
{renderSource('ttsIpDailyLimitAuthenticated')}
|
{renderSource('ttsIpDailyLimitAuthenticated')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
|
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
|
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>
|
<label className="text-xs font-medium text-foreground">Burst limit (parses)</label>
|
||||||
{renderSource('computeParseBurstMax')}
|
{renderSource('computeParseBurstMax')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.computeParseBurstMax ?? '')}
|
value={String(draft.computeParseBurstMax ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('computeParseBurstMax', event.target.value)}
|
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>
|
<label className="text-xs font-medium text-foreground">Burst window (seconds)</label>
|
||||||
{renderSource('computeParseBurstWindowSec')}
|
{renderSource('computeParseBurstWindowSec')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.computeParseBurstWindowSec ?? '')}
|
value={String(draft.computeParseBurstWindowSec ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)}
|
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>
|
<label className="text-xs font-medium text-foreground">Sustained limit (parses)</label>
|
||||||
{renderSource('computeParseSustainedMax')}
|
{renderSource('computeParseSustainedMax')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.computeParseSustainedMax ?? '')}
|
value={String(draft.computeParseSustainedMax ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('computeParseSustainedMax', event.target.value)}
|
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>
|
<label className="text-xs font-medium text-foreground">Sustained window (seconds)</label>
|
||||||
{renderSource('computeParseSustainedWindowSec')}
|
{renderSource('computeParseSustainedWindowSec')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.computeParseSustainedWindowSec ?? '')}
|
value={String(draft.computeParseSustainedWindowSec ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -432,13 +397,13 @@ export function AdminFeaturesPanel() {
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0 self-start pl-1.5">{renderSource('maxUploadMb')}</div>
|
<div className="shrink-0 self-start pl-1.5">{renderSource('maxUploadMb')}</div>
|
||||||
<div className="shrink-0 flex items-center gap-1.5">
|
<div className="shrink-0 flex items-center gap-1.5">
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
aria-label="Max upload size in megabytes"
|
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 ?? '')}
|
value={String(draft.maxUploadMb ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('maxUploadMb', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('maxUploadMb', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -463,9 +428,8 @@ export function AdminFeaturesPanel() {
|
||||||
</div>
|
</div>
|
||||||
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
|
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.changelogFeedUrl ?? '')}
|
value={String(draft.changelogFeedUrl ?? '')}
|
||||||
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
|
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
|
||||||
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
|
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>
|
<label className="text-xs font-medium text-foreground">Retry attempts</label>
|
||||||
{renderSource('ttsUpstreamMaxRetries')}
|
{renderSource('ttsUpstreamMaxRetries')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsUpstreamMaxRetries ?? '')}
|
value={String(draft.ttsUpstreamMaxRetries ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)}
|
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>
|
<label className="text-xs font-medium text-foreground">Upstream timeout (ms)</label>
|
||||||
{renderSource('ttsUpstreamTimeoutMs')}
|
{renderSource('ttsUpstreamTimeoutMs')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsUpstreamTimeoutMs ?? '')}
|
value={String(draft.ttsUpstreamTimeoutMs ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)}
|
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>
|
<label className="text-xs font-medium text-foreground">Audio cache size (bytes)</label>
|
||||||
{renderSource('ttsCacheMaxSizeBytes')}
|
{renderSource('ttsCacheMaxSizeBytes')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsCacheMaxSizeBytes ?? '')}
|
value={String(draft.ttsCacheMaxSizeBytes ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)}
|
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>
|
<label className="text-xs font-medium text-foreground">Audio cache TTL (ms)</label>
|
||||||
{renderSource('ttsCacheTtlMs')}
|
{renderSource('ttsCacheTtlMs')}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
step={1}
|
step={1}
|
||||||
className={inputClass}
|
|
||||||
value={String(draft.ttsCacheTtlMs ?? '')}
|
value={String(draft.ttsCacheTtlMs ?? '')}
|
||||||
onChange={(event) => updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)}
|
onChange={(event) => updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,26 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { ChevronUpDownIcon, CheckIcon, DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
|
import { DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
|
||||||
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
|
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId, type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Field,
|
Field,
|
||||||
Section,
|
Section,
|
||||||
ToggleRow,
|
ToggleRow,
|
||||||
inputClass,
|
Select,
|
||||||
SharedListboxButton,
|
|
||||||
SharedListboxOption,
|
|
||||||
SharedListboxOptions,
|
|
||||||
Button,
|
Button,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
|
Textarea,
|
||||||
MenuItemsSurface,
|
MenuItemsSurface,
|
||||||
MenuActionItem,
|
MenuActionItem,
|
||||||
|
MenuRoot,
|
||||||
|
MenuTrigger,
|
||||||
|
MenuTransition,
|
||||||
} from '@/components/ui';
|
} from '@/components/ui';
|
||||||
|
|
||||||
type ProviderType = TtsProviderId;
|
type ProviderType = TtsProviderId;
|
||||||
|
|
@ -328,9 +327,9 @@ export function AdminProvidersPanel() {
|
||||||
? 'custom'
|
? 'custom'
|
||||||
: modelDefinitions[0]?.id ?? '';
|
: modelDefinitions[0]?.id ?? '';
|
||||||
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
|
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
|
||||||
const modelSupportsInstructions = useCallback((model: string) => resolveTtsProviderModelPolicy({
|
const modelSupportsInstructions = useCallback((model: string, providerType: TtsProviderType = form.providerType) => resolveTtsProviderModelPolicy({
|
||||||
providerRef: form.slug,
|
providerRef: form.slug,
|
||||||
providerType: form.providerType,
|
providerType,
|
||||||
model,
|
model,
|
||||||
}).supportsInstructions, [form.slug, form.providerType]);
|
}).supportsInstructions, [form.slug, form.providerType]);
|
||||||
const baseUrlPlaceholder = form.providerType === 'custom-openai'
|
const baseUrlPlaceholder = form.providerType === 'custom-openai'
|
||||||
|
|
@ -396,7 +395,6 @@ export function AdminProvidersPanel() {
|
||||||
value={form.slug}
|
value={form.slug}
|
||||||
onChange={(e) => setForm({ ...form, slug: e.target.value })}
|
onChange={(e) => setForm({ ...form, slug: e.target.value })}
|
||||||
placeholder="kokoro-prod"
|
placeholder="kokoro-prod"
|
||||||
className={inputClass}
|
|
||||||
disabled={isEditingExisting}
|
disabled={isEditingExisting}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -406,12 +404,20 @@ export function AdminProvidersPanel() {
|
||||||
value={form.displayName}
|
value={form.displayName}
|
||||||
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
|
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
|
||||||
placeholder="Kokoro (production)"
|
placeholder="Kokoro (production)"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Provider type">
|
<Field label="Provider type">
|
||||||
<Listbox
|
<Select
|
||||||
value={selectedProviderType}
|
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) => {
|
onChange={(opt) => {
|
||||||
const nextModel = providerDefaultModel(opt.value);
|
const nextModel = providerDefaultModel(opt.value);
|
||||||
setForm({
|
setForm({
|
||||||
|
|
@ -419,53 +425,33 @@ export function AdminProvidersPanel() {
|
||||||
providerType: opt.value,
|
providerType: opt.value,
|
||||||
baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '',
|
baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '',
|
||||||
defaultModel: nextModel,
|
defaultModel: nextModel,
|
||||||
defaultInstructions: modelSupportsInstructions(nextModel) ? form.defaultInstructions : '',
|
defaultInstructions: modelSupportsInstructions(nextModel, opt.value) ? form.defaultInstructions : '',
|
||||||
});
|
});
|
||||||
setCustomModelInput('');
|
setCustomModelInput('');
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<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>
|
||||||
<Field label="Default model" hint="Pre-selected for users picking this provider.">
|
<Field label="Default model" hint="Pre-selected for users picking this provider.">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Listbox
|
<Select
|
||||||
value={selectedModelId}
|
value={selectedModelDefinition}
|
||||||
onChange={(modelId: string) => {
|
options={modelDefinitions}
|
||||||
if (modelId === 'custom') {
|
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();
|
const nextModel = customModelInput.trim();
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
|
|
@ -476,54 +462,12 @@ export function AdminProvidersPanel() {
|
||||||
}
|
}
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...form,
|
||||||
defaultModel: modelId,
|
defaultModel: model.id,
|
||||||
defaultInstructions: modelSupportsInstructions(modelId) ? form.defaultInstructions : '',
|
defaultInstructions: modelSupportsInstructions(model.id) ? form.defaultInstructions : '',
|
||||||
});
|
});
|
||||||
setCustomModelInput('');
|
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' && (
|
{supportsCustomModel && selectedModelId === 'custom' && (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -538,7 +482,6 @@ export function AdminProvidersPanel() {
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
placeholder="Enter custom model id"
|
placeholder="Enter custom model id"
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -549,11 +492,11 @@ export function AdminProvidersPanel() {
|
||||||
className="sm:col-span-2"
|
className="sm:col-span-2"
|
||||||
hint="Optional. Applied by default when this shared provider is selected."
|
hint="Optional. Applied by default when this shared provider is selected."
|
||||||
>
|
>
|
||||||
<textarea
|
<Textarea
|
||||||
value={form.defaultInstructions}
|
value={form.defaultInstructions}
|
||||||
onChange={(e) => setForm({ ...form, defaultInstructions: e.target.value })}
|
onChange={(e) => setForm({ ...form, defaultInstructions: e.target.value })}
|
||||||
placeholder="Enter instructions for this model"
|
placeholder="Enter instructions for this model"
|
||||||
className={`${inputClass} min-h-24 resize-y`}
|
className="min-h-24 resize-y"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
@ -564,7 +507,6 @@ export function AdminProvidersPanel() {
|
||||||
value={form.baseUrl}
|
value={form.baseUrl}
|
||||||
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
|
||||||
placeholder={baseUrlPlaceholder}
|
placeholder={baseUrlPlaceholder}
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
@ -578,7 +520,6 @@ export function AdminProvidersPanel() {
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
|
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
|
||||||
placeholder={isEditingExisting ? `keep existing (${editingProvider?.apiKeyMask ?? ''})` : 'Optional'}
|
placeholder={isEditingExisting ? `keep existing (${editingProvider?.apiKeyMask ?? ''})` : 'Optional'}
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -639,8 +580,8 @@ export function AdminProvidersPanel() {
|
||||||
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
|
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Menu as="div" className="relative shrink-0">
|
<MenuRoot as="div" className="relative shrink-0">
|
||||||
<MenuButton
|
<MenuTrigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
tone="surface"
|
tone="surface"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -649,16 +590,8 @@ export function AdminProvidersPanel() {
|
||||||
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
|
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
|
||||||
>
|
>
|
||||||
<DotsHorizontalIcon className="h-3 w-4" />
|
<DotsHorizontalIcon className="h-3 w-4" />
|
||||||
</MenuButton>
|
</MenuTrigger>
|
||||||
<Transition
|
<MenuTransition>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<MenuItemsSurface
|
<MenuItemsSurface
|
||||||
anchor="bottom end"
|
anchor="bottom end"
|
||||||
className="z-50 mt-2 min-w-[170px] bg-base focus:outline-none"
|
className="z-50 mt-2 min-w-[170px] bg-base focus:outline-none"
|
||||||
|
|
@ -679,8 +612,8 @@ export function AdminProvidersPanel() {
|
||||||
Delete
|
Delete
|
||||||
</MenuActionItem>
|
</MenuActionItem>
|
||||||
</MenuItemsSurface>
|
</MenuItemsSurface>
|
||||||
</Transition>
|
</MenuTransition>
|
||||||
</Menu>
|
</MenuRoot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Menu, MenuButton, Transition } from '@headlessui/react';
|
import { useRef, type CSSProperties, type ReactNode } from 'react';
|
||||||
import { Fragment, useRef, type CSSProperties, type ReactNode } from 'react';
|
|
||||||
import { useDrop } from 'react-dnd';
|
import { useDrop } from 'react-dnd';
|
||||||
import type { Folder, SidebarFilter } from '@/types/documents';
|
import type { Folder, SidebarFilter } from '@/types/documents';
|
||||||
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
|
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
|
||||||
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
|
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
|
||||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
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 {
|
interface FinderSidebarProps {
|
||||||
filter: SidebarFilter;
|
filter: SidebarFilter;
|
||||||
|
|
@ -201,8 +200,8 @@ export function FinderSidebar({
|
||||||
|
|
||||||
<SidebarNavGroup
|
<SidebarNavGroup
|
||||||
action={(
|
action={(
|
||||||
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
<MenuRoot as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
||||||
<MenuButton
|
<MenuTrigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
size="xs"
|
size="xs"
|
||||||
className="h-3.5 w-5"
|
className="h-3.5 w-5"
|
||||||
|
|
@ -210,16 +209,8 @@ export function FinderSidebar({
|
||||||
aria-label="Folder actions"
|
aria-label="Folder actions"
|
||||||
>
|
>
|
||||||
<DotsHorizontalIcon className="w-4 h-2.5" />
|
<DotsHorizontalIcon className="w-4 h-2.5" />
|
||||||
</MenuButton>
|
</MenuTrigger>
|
||||||
<Transition
|
<MenuTransition>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<MenuItemsSurface
|
<MenuItemsSurface
|
||||||
anchor="bottom start"
|
anchor="bottom start"
|
||||||
className="z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal"
|
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
|
Remove All Folders
|
||||||
</MenuActionItem>
|
</MenuActionItem>
|
||||||
</MenuItemsSurface>
|
</MenuItemsSurface>
|
||||||
</Transition>
|
</MenuTransition>
|
||||||
</Menu>
|
</MenuRoot>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
Folders
|
Folders
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Menu, MenuButton, Transition } from '@headlessui/react';
|
|
||||||
import { Fragment } from 'react';
|
|
||||||
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
|
import { DotsVerticalIcon, FileSettingsIcon, DownloadIcon, ListIcon } from '@/components/icons/Icons';
|
||||||
import { ZoomControl } from '@/components/documents/ZoomControl';
|
import { ZoomControl } from '@/components/documents/ZoomControl';
|
||||||
import { UserMenu } from '@/components/auth/UserMenu';
|
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 {
|
interface DocumentHeaderMenuProps {
|
||||||
zoomLevel: number;
|
zoomLevel: number;
|
||||||
|
|
@ -82,24 +80,16 @@ export function DocumentHeaderMenu({
|
||||||
// --- Mobile View ---
|
// --- Mobile View ---
|
||||||
const MobileView = (
|
const MobileView = (
|
||||||
<div className="sm:hidden flex items-center">
|
<div className="sm:hidden flex items-center">
|
||||||
<Menu as="div" className="relative inline-block text-left">
|
<MenuRoot as="div" className="relative inline-block text-left">
|
||||||
<MenuButton
|
<MenuTrigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
tone="surface"
|
tone="surface"
|
||||||
size="sm"
|
size="sm"
|
||||||
title="Menu"
|
title="Menu"
|
||||||
>
|
>
|
||||||
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
|
<DotsVerticalIcon className="w-4 h-4 transform transition-transform duration-base ease-standard hover:text-accent" />
|
||||||
</MenuButton>
|
</MenuTrigger>
|
||||||
<Transition
|
<MenuTransition>
|
||||||
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"
|
|
||||||
>
|
|
||||||
<MenuItemsSurface className="absolute right-0 z-50 mt-2 min-w-max origin-top-right divide-y divide-line-soft focus:outline-none">
|
<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 */}
|
{/* Zoom Controls Section */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
|
|
@ -140,8 +130,8 @@ export function DocumentHeaderMenu({
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</MenuItemsSurface>
|
</MenuItemsSurface>
|
||||||
</Transition>
|
</MenuTransition>
|
||||||
</Menu>
|
</MenuRoot>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { BaseDocument } from '@/types/documents';
|
import { BaseDocument } from '@/types/documents';
|
||||||
import { Button, ModalFrame, ModalTitle } from '@/components/ui';
|
import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui';
|
||||||
|
|
||||||
interface DocumentSelectionModalProps {
|
interface DocumentSelectionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -119,9 +119,7 @@ export function DocumentSelectionModal({
|
||||||
{files.length > 0 && (
|
{files.length > 0 && (
|
||||||
<div className="flex items-center text-sm font-normal">
|
<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">
|
<label className="flex items-center gap-2 cursor-pointer select-none text-soft hover:text-foreground transition-colors">
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
|
||||||
checked={allSelected}
|
checked={allSelected}
|
||||||
ref={input => {
|
ref={input => {
|
||||||
if (input) input.indeterminate = isIndeterminate;
|
if (input) input.indeterminate = isIndeterminate;
|
||||||
|
|
@ -154,11 +152,9 @@ export function DocumentSelectionModal({
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||||
<input
|
<Checkbox
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
|
onChange={(e) => handleCheckboxChange(file.id, e.target.checked)}
|
||||||
className="rounded border-muted text-accent focus:ring-accent-line"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
||||||
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
import {
|
import {
|
||||||
SEGMENT_PRELOAD_DEPTH_MIN,
|
SEGMENT_PRELOAD_DEPTH_MIN,
|
||||||
|
|
@ -15,10 +16,19 @@ import {
|
||||||
clampSegmentPreloadSentenceLookahead,
|
clampSegmentPreloadSentenceLookahead,
|
||||||
clampTtsSegmentMaxBlockLength,
|
clampTtsSegmentMaxBlockLength,
|
||||||
} from '@/types/config';
|
} 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 { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
|
||||||
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
||||||
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
|
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 }> = [
|
const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [
|
||||||
{ kind: 'header', label: 'Header' },
|
{ kind: 'header', label: 'Header' },
|
||||||
|
|
@ -50,6 +60,20 @@ const viewTypeTextMapping = [
|
||||||
{ id: 'scroll', name: 'Continuous Scroll' },
|
{ 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 = {
|
type RangeSettingProps = {
|
||||||
label: string;
|
label: string;
|
||||||
value: number;
|
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,
|
isOpen: boolean,
|
||||||
setIsOpen: (isOpen: boolean) => void,
|
setIsOpen: (isOpen: boolean) => void,
|
||||||
epub?: boolean,
|
epub?: boolean,
|
||||||
html?: boolean,
|
html?: boolean,
|
||||||
|
language?: string,
|
||||||
|
detectedLanguage?: string | null,
|
||||||
|
onLanguageChange?: (language: string) => void,
|
||||||
pdf?: {
|
pdf?: {
|
||||||
parseStatus: PdfParseStatus | null;
|
parseStatus: PdfParseStatus | null;
|
||||||
parsedOverlayEnabled: boolean;
|
parsedOverlayEnabled: boolean;
|
||||||
|
|
@ -121,7 +148,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
||||||
epubWordHighlightEnabled,
|
epubWordHighlightEnabled,
|
||||||
htmlHighlightEnabled,
|
htmlHighlightEnabled,
|
||||||
htmlWordHighlightEnabled,
|
htmlWordHighlightEnabled,
|
||||||
|
ttsModel,
|
||||||
} = useConfig();
|
} = 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 selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
||||||
const isPdfMode = !epub && !html && !!pdf;
|
const isPdfMode = !epub && !html && !!pdf;
|
||||||
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
|
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
|
||||||
|
|
@ -151,6 +187,34 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
||||||
panelClassName="w-full sm:w-[30rem]"
|
panelClassName="w-full sm:w-[30rem]"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<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 && (
|
{isPdfMode && pdf && (
|
||||||
<Section
|
<Section
|
||||||
title="PDF Essentials"
|
title="PDF Essentials"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Popover } from '@headlessui/react';
|
|
||||||
import { useState, useEffect, useRef } from '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 }: {
|
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
|
|
@ -66,7 +65,7 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{/* Page number popup */}
|
{/* 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}>
|
<PopoverTrigger className="rounded-full bg-surface-sunken px-2 py-0.5 text-xs" onClick={handlePopoverOpen}>
|
||||||
<p className="text-xs whitespace-nowrap">
|
<p className="text-xs whitespace-nowrap">
|
||||||
{currentPage} / {numPages || 1}
|
{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 className="text-xs text-soft text-center">of {numPages || 1}</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</Popover>
|
</PopoverRoot>
|
||||||
|
|
||||||
{/* Page forward */}
|
{/* Page forward */}
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Popover } from '@headlessui/react';
|
|
||||||
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
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 = ({
|
export const SpeedControl = ({
|
||||||
setSpeedAndRestart,
|
setSpeedAndRestart,
|
||||||
|
|
@ -88,7 +87,7 @@ export const SpeedControl = ({
|
||||||
const step = 0.1;
|
const step = 0.1;
|
||||||
|
|
||||||
return (
|
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">
|
<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" />
|
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
<span className="sm:hidden">{compactTriggerLabel}</span>
|
<span className="sm:hidden">{compactTriggerLabel}</span>
|
||||||
|
|
@ -148,6 +147,6 @@ export const SpeedControl = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</Popover>
|
</PopoverRoot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,40 @@ import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
||||||
|
import { InfoIcon } from '@/components/icons/Icons';
|
||||||
|
import { getTtsLanguageCompatibilityWarnings } from '@/lib/shared/language';
|
||||||
|
|
||||||
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
setVoiceAndRestart: (voice: string) => void;
|
setVoiceAndRestart: (voice: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { ttsModel, providerType } = useConfig();
|
const { ttsModel, providerType } = useConfig();
|
||||||
const { voice } = useTTS();
|
const { voice, resolvedLanguage } = useTTS();
|
||||||
const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]);
|
const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]);
|
||||||
|
const languageWarnings = getTtsLanguageCompatibilityWarnings({
|
||||||
|
model: ttsModel,
|
||||||
|
voice,
|
||||||
|
documentLanguage: resolvedLanguage,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VoicesControlBase
|
<div className="flex items-center gap-1">
|
||||||
availableVoices={availableVoices}
|
<VoicesControlBase
|
||||||
voice={voice || ''}
|
availableVoices={availableVoices}
|
||||||
onChangeVoice={onChangeVoice}
|
voice={voice || ''}
|
||||||
providerType={providerType}
|
onChangeVoice={onChangeVoice}
|
||||||
ttsModel={ttsModel}
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ import {
|
||||||
Listbox,
|
Listbox,
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import { ChevronUpDownIcon, AudioWaveIcon, CheckIcon } from '@/components/icons/Icons';
|
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 { buildKokoroVoiceString, parseKokoroVoiceNames } from '@/lib/shared/kokoro';
|
||||||
|
import { keepKokoroVoicesInOneLanguage } from '@/lib/shared/language';
|
||||||
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
import { type TtsProviderType } from '@/lib/shared/tts-provider-catalog';
|
||||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui';
|
import { SharedListboxButton, SharedListboxOption, SharedListboxOptions, cn } from '@/components/ui';
|
||||||
|
|
@ -55,10 +56,15 @@ export function VoicesControlBase({
|
||||||
const isKokoro = providerModelPolicy.isKokoroModel;
|
const isKokoro = providerModelPolicy.isKokoroModel;
|
||||||
const maxVoices = providerModelPolicy.maxVoices;
|
const maxVoices = providerModelPolicy.maxVoices;
|
||||||
|
|
||||||
const [selectedVoices, setSelectedVoices] = useState<string[]>([]);
|
// 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
|
||||||
useEffect(() => {
|
// effect, only in direct response to a user selection (see onChange below).
|
||||||
if (!(isKokoro && maxVoices > 1)) return;
|
// 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[] = [];
|
let initial: string[] = [];
|
||||||
if (voice && voice.includes('+')) {
|
if (voice && voice.includes('+')) {
|
||||||
initial = parseKokoroVoiceNames(voice);
|
initial = parseKokoroVoiceNames(voice);
|
||||||
|
|
@ -70,7 +76,7 @@ export function VoicesControlBase({
|
||||||
if (initial.length > maxVoices) {
|
if (initial.length > maxVoices) {
|
||||||
initial = initial.slice(0, maxVoices);
|
initial = initial.slice(0, maxVoices);
|
||||||
}
|
}
|
||||||
setSelectedVoices(initial);
|
return keepKokoroVoicesInOneLanguage(initial);
|
||||||
}, [isKokoro, maxVoices, voice, availableVoices]);
|
}, [isKokoro, maxVoices, voice, availableVoices]);
|
||||||
|
|
||||||
const currentVoice = useMemo(() => {
|
const currentVoice = useMemo(() => {
|
||||||
|
|
@ -101,19 +107,22 @@ export function VoicesControlBase({
|
||||||
onChange={(vals: string[]) => {
|
onChange={(vals: string[]) => {
|
||||||
if (!vals || vals.length === 0) return;
|
if (!vals || vals.length === 0) return;
|
||||||
|
|
||||||
let next = vals;
|
const newlyAdded = vals.find((v) => !selectedVoices.includes(v));
|
||||||
if (vals.length > maxVoices) {
|
let next = keepKokoroVoicesInOneLanguage(vals, newlyAdded);
|
||||||
const newlyAdded = vals.find((v) => !selectedVoices.includes(v));
|
if (next.length > maxVoices) {
|
||||||
if (newlyAdded) {
|
if (newlyAdded) {
|
||||||
const lastPrev = selectedVoices[selectedVoices.length - 1] ?? selectedVoices[0] ?? '';
|
const compatiblePrevious = selectedVoices.filter(
|
||||||
const pair = Array.from(new Set([lastPrev, newlyAdded])).filter(Boolean);
|
(voiceId) => voiceId !== newlyAdded && next.includes(voiceId),
|
||||||
next = pair.slice(0, maxVoices);
|
);
|
||||||
|
next = [
|
||||||
|
...compatiblePrevious.slice(-(maxVoices - 1)),
|
||||||
|
newlyAdded,
|
||||||
|
];
|
||||||
} else {
|
} else {
|
||||||
next = vals.slice(-maxVoices);
|
next = next.slice(-maxVoices);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedVoices(next);
|
|
||||||
const combined = buildKokoroVoiceString(next);
|
const combined = buildKokoroVoiceString(next);
|
||||||
if (combined) {
|
if (combined) {
|
||||||
onChangeVoice(combined);
|
onChangeVoice(combined);
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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 { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import type { Book } from 'epubjs';
|
import type { Book } from 'epubjs';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { RefreshIcon, InfoIcon } from '@/components/icons/Icons';
|
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 { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
import { compareSegmentLocators, locatorGroupKey, locatorIdentityKey } from '@/lib/shared/tts-locator';
|
||||||
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
|
import { buildSegmentKey, buildSegmentKeyPrefix } from '@/lib/shared/tts-segment-plan';
|
||||||
|
|
@ -94,6 +94,7 @@ function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings |
|
||||||
&& a.voice === b.voice
|
&& a.voice === b.voice
|
||||||
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
|
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
|
||||||
&& (a.ttsInstructions || '') === (b.ttsInstructions || '')
|
&& (a.ttsInstructions || '') === (b.ttsInstructions || '')
|
||||||
|
&& (a.language || 'en') === (b.language || 'en')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,6 +187,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
||||||
playFromSegment,
|
playFromSegment,
|
||||||
activeReaderType,
|
activeReaderType,
|
||||||
clearSegmentCaches,
|
clearSegmentCaches,
|
||||||
|
resolvedLanguage,
|
||||||
|
setDocumentLanguage,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
const {
|
const {
|
||||||
providerRef,
|
providerRef,
|
||||||
|
|
@ -348,7 +351,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
||||||
voice,
|
voice,
|
||||||
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
|
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
|
||||||
ttsInstructions: ttsInstructions || '',
|
ttsInstructions: ttsInstructions || '',
|
||||||
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions]);
|
language: resolvedLanguage,
|
||||||
|
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions, resolvedLanguage]);
|
||||||
|
|
||||||
const handleClearCache = useCallback(async () => {
|
const handleClearCache = useCallback(async () => {
|
||||||
if (!documentId || isClearingSegments) return;
|
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('voiceSpeed', Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1),
|
||||||
updateConfigKey('ttsInstructions', settings.ttsInstructions || ''),
|
updateConfigKey('ttsInstructions', settings.ttsInstructions || ''),
|
||||||
]);
|
]);
|
||||||
}, [updateConfigKey]);
|
if (settings.language) setDocumentLanguage(settings.language);
|
||||||
|
}, [updateConfigKey, setDocumentLanguage]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
didAutoScrollOnOpenRef.current = false;
|
didAutoScrollOnOpenRef.current = false;
|
||||||
|
|
@ -885,15 +890,14 @@ function SegmentsListSkeletonRows() {
|
||||||
|
|
||||||
function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
||||||
return (
|
return (
|
||||||
<Popover className="relative shrink-0">
|
<PopoverRoot className="relative shrink-0">
|
||||||
<PopoverButton
|
<PopoverIconTrigger
|
||||||
as={IconButton}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label="Segment metadata"
|
aria-label="Segment metadata"
|
||||||
title="Metadata"
|
title="Metadata"
|
||||||
>
|
>
|
||||||
<InfoIcon className="w-3.5 h-3.5" />
|
<InfoIcon className="w-3.5 h-3.5" />
|
||||||
</PopoverButton>
|
</PopoverIconTrigger>
|
||||||
<Transition
|
<Transition
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
enter="transition ease-standard duration-fast"
|
enter="transition ease-standard duration-fast"
|
||||||
|
|
@ -960,7 +964,7 @@ function SegmentMetadataPopover({ row }: { row: TTSSegmentRow }) {
|
||||||
</dl>
|
</dl>
|
||||||
</PopoverSurface>
|
</PopoverSurface>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Popover>
|
</PopoverRoot>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { focusRing, motionColors } from './tokens';
|
||||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
|
||||||
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
|
export type ButtonSize = 'xs' | 'sm' | 'md' | 'lg' | 'icon';
|
||||||
|
|
||||||
export const buttonStyles = variants({
|
const buttonStyles = variants({
|
||||||
base: cn(
|
base: cn(
|
||||||
'inline-flex items-center justify-center font-medium disabled:cursor-not-allowed disabled:opacity-50',
|
'inline-flex items-center justify-center font-medium disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
focusRing,
|
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({
|
function buttonClass({
|
||||||
variant = 'secondary',
|
variant = 'secondary',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
|
|
@ -112,3 +101,22 @@ export function ButtonAnchor({
|
||||||
</a>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
16
src/components/ui/checkbox.tsx
Normal file
16
src/components/ui/checkbox.tsx
Normal 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} />;
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './badge';
|
export * from './badge';
|
||||||
export * from './button';
|
export * from './button';
|
||||||
|
export * from './checkbox';
|
||||||
export * from './choice-tile';
|
export * from './choice-tile';
|
||||||
export * from './cn';
|
export * from './cn';
|
||||||
export * from './dialog';
|
export * from './dialog';
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { motionColors } from './tokens';
|
||||||
|
|
||||||
export type InputControlSize = 'sm' | 'md' | 'lg';
|
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),
|
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: {
|
variants: {
|
||||||
size: {
|
size: {
|
||||||
|
|
@ -19,8 +19,6 @@ export const inputStyles = variants({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const inputClass = inputStyles();
|
|
||||||
|
|
||||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
|
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
|
||||||
className,
|
className,
|
||||||
controlSize = 'md',
|
controlSize = 'md',
|
||||||
|
|
|
||||||
|
|
@ -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 { ComponentProps } from 'react';
|
||||||
import type { HTMLAttributes, ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
|
|
||||||
const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
|
const menuPanelClass = 'rounded-md border border-line bg-surface p-1 shadow-elev-2 ring-1 ring-line-soft';
|
||||||
|
|
||||||
export function Menu({
|
export const MenuRoot = HeadlessMenu;
|
||||||
children,
|
export const MenuTrigger = HeadlessMenuButton;
|
||||||
className,
|
|
||||||
...props
|
export function MenuTransition({ children }: { children: ReactNode }) {
|
||||||
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
|
|
||||||
return (
|
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}
|
{children}
|
||||||
</div>
|
</Transition>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { PopoverButton, PopoverPanel } from '@headlessui/react';
|
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||||
import type { ComponentProps } from 'react';
|
import type { ComponentProps } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
|
import { IconButton } from './icon-button';
|
||||||
|
|
||||||
const popoverPanelClass = cn(
|
const popoverPanelClass = cn(
|
||||||
'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none',
|
'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',
|
'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({
|
export function PopoverSurface({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
|
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
|
||||||
import type { ComponentProps } from 'react';
|
import { Fragment, type ComponentProps, type Key, type ReactNode } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
import { CheckIcon, ChevronRightIcon } from '@/components/icons/Icons';
|
import { CheckIcon, ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||||
|
|
||||||
const listboxButtonClass =
|
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';
|
'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,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
|
getOptionKey,
|
||||||
|
renderValue,
|
||||||
|
renderOption,
|
||||||
|
placeholder = 'Select',
|
||||||
|
disabled,
|
||||||
|
buttonClassName,
|
||||||
|
optionsClassName,
|
||||||
|
optionInset = 'check',
|
||||||
|
optionItemClassName,
|
||||||
|
showCheckmark = true,
|
||||||
|
chevronClassName = 'h-5 w-5 text-soft',
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: T | undefined;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: T) => void;
|
||||||
options: Array<{ value: string; label: string }>;
|
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 (
|
return (
|
||||||
<Listbox value={value} onChange={onChange}>
|
<Listbox value={value} onChange={onChange} disabled={disabled}>
|
||||||
<SharedListboxButton>
|
<SharedListboxButton className={buttonClassName}>
|
||||||
<span>{activeOption?.label ?? 'Select'}</span>
|
<span className="block truncate">{value === undefined ? placeholder : valueRenderer(value)}</span>
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-2 flex items-center text-soft">
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
<ChevronRightIcon className="h-4 w-4 rotate-90" aria-hidden="true" />
|
<ChevronUpDownIcon className={chevronClassName} aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
</SharedListboxButton>
|
</SharedListboxButton>
|
||||||
<SharedListboxOptions anchor="bottom">
|
<Transition
|
||||||
{options.map((option) => (
|
as={Fragment}
|
||||||
<SharedListboxOption key={option.value} value={option.value}>
|
leave="transition ease-standard duration-fast"
|
||||||
{({ selected }) => (
|
leaveFrom="opacity-100"
|
||||||
<>
|
leaveTo="opacity-0"
|
||||||
<span className="absolute left-2 flex items-center text-accent">
|
>
|
||||||
{selected ? <CheckIcon className="h-4 w-4" aria-hidden="true" /> : null}
|
<SharedListboxOptions anchor="bottom start" className={optionsClassName}>
|
||||||
</span>
|
{options.map((option) => (
|
||||||
<span>{option.label}</span>
|
<SharedListboxOption
|
||||||
</>
|
key={optionKey(option)}
|
||||||
)}
|
value={option}
|
||||||
</SharedListboxOption>
|
inset={optionInset}
|
||||||
))}
|
itemClassName={optionItemClassName}
|
||||||
</SharedListboxOptions>
|
>
|
||||||
|
{({ 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>
|
</Listbox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export function HTMLViewer({
|
||||||
currentSentence,
|
currentSentence,
|
||||||
currentSentenceAlignment,
|
currentSentenceAlignment,
|
||||||
currentWordIndex,
|
currentWordIndex,
|
||||||
|
resolvedLanguage,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
const { htmlHighlightEnabled, htmlWordHighlightEnabled } = useConfig();
|
const { htmlHighlightEnabled, htmlWordHighlightEnabled } = useConfig();
|
||||||
|
|
||||||
|
|
@ -96,7 +97,7 @@ export function HTMLViewer({
|
||||||
// here (not in cleanup) avoids a Strict-Mode-induced wipe of the very
|
// here (not in cleanup) avoids a Strict-Mode-induced wipe of the very
|
||||||
// first highlight.
|
// first highlight.
|
||||||
clearHtmlSentenceHighlight();
|
clearHtmlSentenceHighlight();
|
||||||
const matched = highlightHtmlSentence(container, currentSentence);
|
const matched = highlightHtmlSentence(container, currentSentence, resolvedLanguage);
|
||||||
if (matched) {
|
if (matched) {
|
||||||
scrollSentenceIntoView(scrollRef.current);
|
scrollSentenceIntoView(scrollRef.current);
|
||||||
return;
|
return;
|
||||||
|
|
@ -118,6 +119,7 @@ export function HTMLViewer({
|
||||||
}, [
|
}, [
|
||||||
htmlHighlightEnabled,
|
htmlHighlightEnabled,
|
||||||
currentSentence,
|
currentSentence,
|
||||||
|
resolvedLanguage,
|
||||||
blocks,
|
blocks,
|
||||||
clearSentenceTimeouts,
|
clearSentenceTimeouts,
|
||||||
scheduleSentence,
|
scheduleSentence,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
|
||||||
currentSentenceAlignment,
|
currentSentenceAlignment,
|
||||||
currentSegment,
|
currentSegment,
|
||||||
skipToLocation,
|
skipToLocation,
|
||||||
|
resolvedLanguage,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -207,6 +208,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
|
||||||
parsedDocument,
|
parsedDocument,
|
||||||
locator: activeLocator,
|
locator: activeLocator,
|
||||||
useBlockGeometryOnly,
|
useBlockGeometryOnly,
|
||||||
|
language: resolvedLanguage,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -224,6 +226,7 @@ export function PDFViewer({ zoomLevel, onDocumentReady, pdfState }: PDFViewerPro
|
||||||
pdfHighlightEnabled,
|
pdfHighlightEnabled,
|
||||||
pdfWordHighlightEnabled,
|
pdfWordHighlightEnabled,
|
||||||
parsedDocument,
|
parsedDocument,
|
||||||
|
resolvedLanguage,
|
||||||
layoutKey,
|
layoutKey,
|
||||||
isPageRendering,
|
isPageRendering,
|
||||||
clearSentenceHighlightTimeouts,
|
clearSentenceHighlightTimeouts,
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ import {
|
||||||
} from '@/lib/client/epub/tts-epub-handoff';
|
} from '@/lib/client/epub/tts-epub-handoff';
|
||||||
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator';
|
||||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
|
import { resolveTtsLanguage } from '@/lib/shared/language';
|
||||||
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
|
||||||
import type {
|
import type {
|
||||||
EpubRenderedLocationWalker,
|
EpubRenderedLocationWalker,
|
||||||
|
|
@ -146,6 +147,9 @@ interface TTSContextType extends TTSPlaybackState {
|
||||||
setSpeedAndRestart: (speed: number) => void;
|
setSpeedAndRestart: (speed: number) => void;
|
||||||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||||||
setVoiceAndRestart: (voice: string) => void;
|
setVoiceAndRestart: (voice: string) => void;
|
||||||
|
documentLanguage: string;
|
||||||
|
resolvedLanguage: string;
|
||||||
|
setDocumentLanguage: (language: string) => void;
|
||||||
clearSegmentCaches: () => void;
|
clearSegmentCaches: () => void;
|
||||||
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
|
skipToLocation: (location: TTSLocation, shouldPause?: boolean) => void;
|
||||||
registerLocationChangeHandler: (handler: ((location: TTSLocation) => void) | null) => void; // EPUB-only: Handles chapter navigation
|
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 normalizeBlockFingerprint = (text: string): string => {
|
||||||
const normalized = preprocessSentenceForAudio(text)
|
const normalized = preprocessSentenceForAudio(text)
|
||||||
|
.normalize('NFKC')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9\s]/g, ' ')
|
.replace(/[^\p{L}\p{N}\p{M}\s]/gu, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
return normalized.slice(0, 200);
|
return normalized.slice(0, 200);
|
||||||
|
|
@ -249,6 +254,7 @@ const buildCacheKey = (
|
||||||
model: string,
|
model: string,
|
||||||
providerType: string,
|
providerType: string,
|
||||||
instructions: string,
|
instructions: string,
|
||||||
|
language: string,
|
||||||
) => {
|
) => {
|
||||||
return [
|
return [
|
||||||
`provider=${provider || ''}`,
|
`provider=${provider || ''}`,
|
||||||
|
|
@ -257,6 +263,7 @@ const buildCacheKey = (
|
||||||
`voice=${voice || ''}`,
|
`voice=${voice || ''}`,
|
||||||
`speed=${Number.isFinite(speed) ? speed : ''}`,
|
`speed=${Number.isFinite(speed) ? speed : ''}`,
|
||||||
`instructions=${instructions || ''}`,
|
`instructions=${instructions || ''}`,
|
||||||
|
`language=${language || ''}`,
|
||||||
`text=${sentence}`,
|
`text=${sentence}`,
|
||||||
].join('|');
|
].join('|');
|
||||||
};
|
};
|
||||||
|
|
@ -271,10 +278,11 @@ const buildScopedSegmentCacheKey = (
|
||||||
model: string,
|
model: string,
|
||||||
providerType: string,
|
providerType: string,
|
||||||
instructions: string,
|
instructions: string,
|
||||||
|
language: string,
|
||||||
segmentKey?: string | null,
|
segmentKey?: string | null,
|
||||||
) => {
|
) => {
|
||||||
return [
|
return [
|
||||||
buildCacheKey(sentence, voice, speed, provider, model, providerType, instructions),
|
buildCacheKey(sentence, voice, speed, provider, model, providerType, instructions, language),
|
||||||
`segmentKey=${segmentKey || ''}`,
|
`segmentKey=${segmentKey || ''}`,
|
||||||
`locator=${segmentKey ? '' : buildLocatorRequestKey(locator)}`,
|
`locator=${segmentKey ? '' : buildLocatorRequestKey(locator)}`,
|
||||||
`segmentIndex=${segmentKey ? '' : segmentIndex}`,
|
`segmentIndex=${segmentKey ? '' : segmentIndex}`,
|
||||||
|
|
@ -567,6 +575,11 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
const [voice, setVoice] = useState(configVoice);
|
const [voice, setVoice] = useState(configVoice);
|
||||||
const [ttsModel, setTTSModel] = useState(configTTSModel);
|
const [ttsModel, setTTSModel] = useState(configTTSModel);
|
||||||
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
|
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
|
||||||
|
const [documentLanguage, setDocumentLanguage] = useState('auto');
|
||||||
|
const resolvedLanguage = useMemo(
|
||||||
|
() => resolveTtsLanguage({ configuredLanguage: documentLanguage, voice }),
|
||||||
|
[documentLanguage, voice],
|
||||||
|
);
|
||||||
const providerModelPolicy = useMemo(
|
const providerModelPolicy = useMemo(
|
||||||
() => resolveTtsProviderModelPolicy({
|
() => resolveTtsProviderModelPolicy({
|
||||||
providerRef: configProviderRef,
|
providerRef: configProviderRef,
|
||||||
|
|
@ -1231,6 +1244,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||||
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
keyPrefix: buildSegmentKeyPrefix(documentId, activeReaderType),
|
||||||
enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0,
|
enforceSourceBoundaries: activeReaderType === 'pdf' && currentUnits !== null && currentUnits.length > 0,
|
||||||
|
language: resolvedLanguage,
|
||||||
});
|
});
|
||||||
const currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey));
|
const currentSegments = plan.segments.filter((segment) => currentSourceKeySet.has(segment.ownerSourceKey));
|
||||||
const newSentences = currentSegments.map((segment) => segment.text);
|
const newSentences = currentSegments.map((segment) => segment.text);
|
||||||
|
|
@ -1383,6 +1397,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
currDocPage,
|
currDocPage,
|
||||||
documentId,
|
documentId,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
|
resolvedLanguage,
|
||||||
clearPendingEpubJump,
|
clearPendingEpubJump,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -1496,6 +1511,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
voice,
|
voice,
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||||
|
resolvedLanguage,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
].join('|');
|
].join('|');
|
||||||
|
|
||||||
|
|
@ -1516,6 +1532,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
providerModelPolicy.supportsInstructions,
|
providerModelPolicy.supportsInstructions,
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
|
resolvedLanguage,
|
||||||
ttsSegmentMaxBlockLength,
|
ttsSegmentMaxBlockLength,
|
||||||
clearPendingEpubJump,
|
clearPendingEpubJump,
|
||||||
bumpEpubPreloadGeneration,
|
bumpEpubPreloadGeneration,
|
||||||
|
|
@ -1589,6 +1606,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsModel,
|
ttsModel,
|
||||||
configProviderType,
|
configProviderType,
|
||||||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||||
|
resolvedLanguage,
|
||||||
segmentKey,
|
segmentKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1669,6 +1687,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
voice,
|
voice,
|
||||||
nativeSpeed: effectiveNativeSpeed,
|
nativeSpeed: effectiveNativeSpeed,
|
||||||
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||||||
|
language: resolvedLanguage,
|
||||||
},
|
},
|
||||||
segments: persistSegments,
|
segments: persistSegments,
|
||||||
}, reqHeaders, controller.signal),
|
}, reqHeaders, controller.signal),
|
||||||
|
|
@ -1772,6 +1791,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
effectiveNativeSpeed,
|
effectiveNativeSpeed,
|
||||||
ttsModel,
|
ttsModel,
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
|
resolvedLanguage,
|
||||||
openApiKey,
|
openApiKey,
|
||||||
openApiBaseUrl,
|
openApiBaseUrl,
|
||||||
configProviderRef,
|
configProviderRef,
|
||||||
|
|
@ -2253,6 +2273,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsModel,
|
ttsModel,
|
||||||
configProviderType,
|
configProviderType,
|
||||||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||||
|
resolvedLanguage,
|
||||||
playbackSegment?.key,
|
playbackSegment?.key,
|
||||||
);
|
);
|
||||||
const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey);
|
const cachedAlignment = sentenceAlignmentCacheRef.current.get(alignmentKey);
|
||||||
|
|
@ -2290,6 +2311,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
configProviderType,
|
configProviderType,
|
||||||
providerModelPolicy.supportsInstructions,
|
providerModelPolicy.supportsInstructions,
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
|
resolvedLanguage,
|
||||||
isEPUB,
|
isEPUB,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocPageNumber,
|
currDocPageNumber,
|
||||||
|
|
@ -2388,6 +2410,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsModel,
|
ttsModel,
|
||||||
configProviderType,
|
configProviderType,
|
||||||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||||
|
resolvedLanguage,
|
||||||
nextSegment?.key,
|
nextSegment?.key,
|
||||||
);
|
);
|
||||||
if (segmentManifestCacheRef.current.has(cacheKey)) {
|
if (segmentManifestCacheRef.current.has(cacheKey)) {
|
||||||
|
|
@ -2498,6 +2521,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
readerType: 'epub',
|
readerType: 'epub',
|
||||||
maxBlockLength: ttsSegmentMaxBlockLength,
|
maxBlockLength: ttsSegmentMaxBlockLength,
|
||||||
keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'),
|
keyPrefix: buildSegmentKeyPrefix(documentId, 'epub'),
|
||||||
|
language: resolvedLanguage,
|
||||||
});
|
});
|
||||||
const uniqueCandidates: Array<EpubLocationPreloadCandidate & { locator: TTSSegmentLocator }> = [];
|
const uniqueCandidates: Array<EpubLocationPreloadCandidate & { locator: TTSSegmentLocator }> = [];
|
||||||
const seenCandidates = new Set<string>();
|
const seenCandidates = new Set<string>();
|
||||||
|
|
@ -2520,6 +2544,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsModel,
|
ttsModel,
|
||||||
configProviderType,
|
configProviderType,
|
||||||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||||
|
resolvedLanguage,
|
||||||
segment.key,
|
segment.key,
|
||||||
);
|
);
|
||||||
if (seenCandidates.has(requestKey)) continue;
|
if (seenCandidates.has(requestKey)) continue;
|
||||||
|
|
@ -2564,6 +2589,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
voice,
|
voice,
|
||||||
nativeSpeed: effectiveNativeSpeed,
|
nativeSpeed: effectiveNativeSpeed,
|
||||||
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||||||
|
language: resolvedLanguage,
|
||||||
},
|
},
|
||||||
segments: persistPayload,
|
segments: persistPayload,
|
||||||
}, reqHeaders, controller.signal),
|
}, reqHeaders, controller.signal),
|
||||||
|
|
@ -2660,6 +2686,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsModel,
|
ttsModel,
|
||||||
configProviderType,
|
configProviderType,
|
||||||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||||
|
resolvedLanguage,
|
||||||
plannedSegment?.key,
|
plannedSegment?.key,
|
||||||
);
|
);
|
||||||
const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, plannedSegment?.key);
|
const requestKey = buildSegmentRequestKey(locator, sentenceIndex, sentence, plannedSegment?.key);
|
||||||
|
|
@ -2691,6 +2718,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
ttsModel,
|
ttsModel,
|
||||||
configProviderType,
|
configProviderType,
|
||||||
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
providerModelPolicy.supportsInstructions ? ttsInstructions : '',
|
||||||
|
resolvedLanguage,
|
||||||
segment.key,
|
segment.key,
|
||||||
);
|
);
|
||||||
const requestKey = buildSegmentRequestKey(segmentLocator, index, sentence, segment.key);
|
const requestKey = buildSegmentRequestKey(segmentLocator, index, sentence, segment.key);
|
||||||
|
|
@ -2755,6 +2783,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
voice,
|
voice,
|
||||||
nativeSpeed: effectiveNativeSpeed,
|
nativeSpeed: effectiveNativeSpeed,
|
||||||
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
...(providerModelPolicy.supportsInstructions && ttsInstructions ? { ttsInstructions } : {}),
|
||||||
|
language: resolvedLanguage,
|
||||||
},
|
},
|
||||||
segments: persistPayload,
|
segments: persistPayload,
|
||||||
}, reqHeaders, controller.signal),
|
}, reqHeaders, controller.signal),
|
||||||
|
|
@ -2841,6 +2870,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
openApiBaseUrl,
|
openApiBaseUrl,
|
||||||
providerModelPolicy.supportsInstructions,
|
providerModelPolicy.supportsInstructions,
|
||||||
ttsInstructions,
|
ttsInstructions,
|
||||||
|
resolvedLanguage,
|
||||||
onTTSStart,
|
onTTSStart,
|
||||||
onTTSComplete,
|
onTTSComplete,
|
||||||
processSentence,
|
processSentence,
|
||||||
|
|
@ -3145,6 +3175,9 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setSpeedAndRestart,
|
setSpeedAndRestart,
|
||||||
setAudioPlayerSpeedAndRestart,
|
setAudioPlayerSpeedAndRestart,
|
||||||
setVoiceAndRestart,
|
setVoiceAndRestart,
|
||||||
|
documentLanguage,
|
||||||
|
resolvedLanguage,
|
||||||
|
setDocumentLanguage,
|
||||||
clearSegmentCaches,
|
clearSegmentCaches,
|
||||||
skipToLocation,
|
skipToLocation,
|
||||||
registerLocationChangeHandler,
|
registerLocationChangeHandler,
|
||||||
|
|
@ -3176,6 +3209,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
||||||
setSpeedAndRestart,
|
setSpeedAndRestart,
|
||||||
setAudioPlayerSpeedAndRestart,
|
setAudioPlayerSpeedAndRestart,
|
||||||
setVoiceAndRestart,
|
setVoiceAndRestart,
|
||||||
|
documentLanguage,
|
||||||
|
resolvedLanguage,
|
||||||
clearSegmentCaches,
|
clearSegmentCaches,
|
||||||
skipToLocation,
|
skipToLocation,
|
||||||
registerLocationChangeHandler,
|
registerLocationChangeHandler,
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,15 @@ import { useCallback, useEffect, type MutableRefObject, type RefObject } from 'r
|
||||||
import type { Rendition } from 'epubjs';
|
import type { Rendition } from 'epubjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildMonotonicWordToTokenMap,
|
|
||||||
buildWordHighlightCacheKey,
|
buildWordHighlightCacheKey,
|
||||||
|
resolveAlignmentWordSourceRange,
|
||||||
tokenizeCanonicalSegment,
|
tokenizeCanonicalSegment,
|
||||||
type EpubCanonicalWordToken,
|
type EpubCanonicalWordToken,
|
||||||
} from '@/lib/client/epub/epub-word-highlight';
|
} from '@/lib/client/epub/epub-word-highlight';
|
||||||
|
import {
|
||||||
|
buildAlignmentTokenRanges,
|
||||||
|
type HighlightTokenRange,
|
||||||
|
} from '@/lib/client/highlight-token-alignment';
|
||||||
import {
|
import {
|
||||||
createRangeFromMappedOffsets,
|
createRangeFromMappedOffsets,
|
||||||
resolveVisibleSegmentRange,
|
resolveVisibleSegmentRange,
|
||||||
|
|
@ -19,7 +23,7 @@ import type { TTSSentenceAlignment } from '@/types/tts';
|
||||||
|
|
||||||
export type EpubWordHighlightMapCache = {
|
export type EpubWordHighlightMapCache = {
|
||||||
key: string;
|
key: string;
|
||||||
wordToToken: number[];
|
wordToTokenRange: Array<HighlightTokenRange | null>;
|
||||||
tokens: EpubCanonicalWordToken[];
|
tokens: EpubCanonicalWordToken[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -30,6 +34,7 @@ type UseEpubHighlightingParams = {
|
||||||
currentWordHighlightCfiRef: MutableRefObject<string | null>;
|
currentWordHighlightCfiRef: MutableRefObject<string | null>;
|
||||||
renderedTextMapsRef: MutableRefObject<EpubRenderedTextMap[]>;
|
renderedTextMapsRef: MutableRefObject<EpubRenderedTextMap[]>;
|
||||||
wordHighlightMapCacheRef: MutableRefObject<EpubWordHighlightMapCache | null>;
|
wordHighlightMapCacheRef: MutableRefObject<EpubWordHighlightMapCache | null>;
|
||||||
|
language?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseEpubHighlightingResult = {
|
type UseEpubHighlightingResult = {
|
||||||
|
|
@ -52,6 +57,7 @@ export function useEPUBHighlighting({
|
||||||
currentWordHighlightCfiRef,
|
currentWordHighlightCfiRef,
|
||||||
renderedTextMapsRef,
|
renderedTextMapsRef,
|
||||||
wordHighlightMapCacheRef,
|
wordHighlightMapCacheRef,
|
||||||
|
language,
|
||||||
}: UseEpubHighlightingParams): UseEpubHighlightingResult {
|
}: UseEpubHighlightingParams): UseEpubHighlightingResult {
|
||||||
const clearWordHighlights = useCallback(() => {
|
const clearWordHighlights = useCallback(() => {
|
||||||
if (!renditionRef.current) return;
|
if (!renditionRef.current) return;
|
||||||
|
|
@ -116,25 +122,68 @@ export function useEPUBHighlighting({
|
||||||
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
|
const resolved = resolveVisibleSegmentRange(renderedTextMapsRef.current, segment);
|
||||||
if (!resolved || segment.startAnchor.sourceKey !== resolved.map.sourceKey) return;
|
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) {
|
if (wordHighlightMapCacheRef.current?.key !== cacheKey) {
|
||||||
const tokens = tokenizeCanonicalSegment(segment);
|
const tokens = tokenizeCanonicalSegment(segment, language);
|
||||||
wordHighlightMapCacheRef.current = {
|
wordHighlightMapCacheRef.current = {
|
||||||
key: cacheKey,
|
key: cacheKey,
|
||||||
tokens,
|
tokens,
|
||||||
wordToToken: buildMonotonicWordToTokenMap(words, tokens),
|
wordToTokenRange: buildAlignmentTokenRanges(
|
||||||
|
words,
|
||||||
|
tokens.map((token) => token.norm),
|
||||||
|
{ minimumSimilarity: 0.8 },
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = wordHighlightMapCacheRef.current;
|
const cached = wordHighlightMapCacheRef.current;
|
||||||
const tokenIndex = cached.wordToToken[wordIndex] ?? -1;
|
const tokenRange = cached.wordToTokenRange[wordIndex];
|
||||||
if (tokenIndex < 0) return;
|
if (!tokenRange) return;
|
||||||
|
|
||||||
const token = cached.tokens[tokenIndex];
|
const firstToken = cached.tokens[tokenRange.start];
|
||||||
if (!token) return;
|
const lastToken = cached.tokens[tokenRange.end];
|
||||||
if (token.sourceStart < resolved.startOffset || token.sourceEnd > resolved.endOffset) return;
|
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;
|
if (!wordRange) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -162,6 +211,7 @@ export function useEPUBHighlighting({
|
||||||
renderedTextMapsRef,
|
renderedTextMapsRef,
|
||||||
renditionRef,
|
renditionRef,
|
||||||
wordHighlightMapCacheRef,
|
wordHighlightMapCacheRef,
|
||||||
|
language,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const setRenderedTextMaps = useCallback((maps: EpubRenderedTextMap[]) => {
|
const setRenderedTextMaps = useCallback((maps: EpubRenderedTextMap[]) => {
|
||||||
|
|
|
||||||
55
src/hooks/useDocumentLanguage.ts
Normal file
55
src/hooks/useDocumentLanguage.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
TTSAudiobookChapter,
|
TTSAudiobookChapter,
|
||||||
TTSAudiobookFormat,
|
TTSAudiobookFormat,
|
||||||
} from '@/types/tts';
|
} from '@/types/tts';
|
||||||
|
import { normalizeTextForTts } from '@/lib/shared/nlp';
|
||||||
|
|
||||||
export interface PreparedAudiobookChapter {
|
export interface PreparedAudiobookChapter {
|
||||||
index: number;
|
index: number;
|
||||||
|
|
@ -109,7 +110,10 @@ export async function runAudiobookGeneration({
|
||||||
maxDelay: 300,
|
maxDelay: 300,
|
||||||
},
|
},
|
||||||
}: RunAudiobookGenerationOptions): Promise<string> {
|
}: 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);
|
const totalLength = chapters.reduce((sum, chapter) => sum + chapter.text.trim().length, 0);
|
||||||
if (totalLength === 0) {
|
if (totalLength === 0) {
|
||||||
throw new Error(adapter.noContentMessage);
|
throw new Error(adapter.noContentMessage);
|
||||||
|
|
@ -228,7 +232,7 @@ export async function regenerateAudiobookChapter({
|
||||||
},
|
},
|
||||||
}: RegenerateAudiobookChapterOptions): Promise<TTSAudiobookChapter> {
|
}: RegenerateAudiobookChapterOptions): Promise<TTSAudiobookChapter> {
|
||||||
const chapter = await adapter.prepareChapter(chapterIndex);
|
const chapter = await adapter.prepareChapter(chapterIndex);
|
||||||
const trimmedText = chapter.text.trim();
|
const trimmedText = normalizeTextForTts(chapter.text, { language: settings?.language }).trim();
|
||||||
if (!trimmedText) {
|
if (!trimmedText) {
|
||||||
throw new Error(adapter.noContentMessage);
|
throw new Error(adapter.noContentMessage);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
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 = {
|
export type EpubCanonicalWordToken = {
|
||||||
norm: string;
|
norm: string;
|
||||||
|
|
@ -8,107 +10,43 @@ export type EpubCanonicalWordToken = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizeWordForHighlight = (text: string): string =>
|
export const normalizeWordForHighlight = (text: string): string =>
|
||||||
text
|
normalizeHighlightToken(text);
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '');
|
|
||||||
|
|
||||||
export const tokenizeCanonicalSegment = (segment: CanonicalTtsSegment): EpubCanonicalWordToken[] => {
|
export const resolveAlignmentWordSourceRange = (
|
||||||
const tokens: EpubCanonicalWordToken[] = [];
|
segment: CanonicalTtsSegment,
|
||||||
const wordRegex = /\S+/g;
|
word: TTSSentenceWord,
|
||||||
let match: RegExpExecArray | null;
|
): { 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) {
|
return {
|
||||||
const raw = match[0];
|
sourceStart: segment.startAnchor.offset + charStart,
|
||||||
const leading = raw.match(/^[^A-Za-z0-9]*/)?.[0].length ?? 0;
|
sourceEnd: segment.startAnchor.offset + charEnd,
|
||||||
const trailing = raw.match(/[^A-Za-z0-9]*$/)?.[0].length ?? 0;
|
};
|
||||||
const start = match.index + leading;
|
|
||||||
const end = match.index + raw.length - trailing;
|
|
||||||
if (end <= start) continue;
|
|
||||||
|
|
||||||
const norm = normalizeWordForHighlight(raw.slice(leading, raw.length - trailing));
|
|
||||||
if (!norm) continue;
|
|
||||||
|
|
||||||
tokens.push({
|
|
||||||
norm,
|
|
||||||
sourceStart: segment.startAnchor.offset + start,
|
|
||||||
sourceEnd: segment.startAnchor.offset + end,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildMonotonicWordToTokenMap = (
|
export const tokenizeCanonicalSegment = (
|
||||||
alignmentWords: TTSSentenceAlignment['words'],
|
segment: CanonicalTtsSegment,
|
||||||
segmentTokens: EpubCanonicalWordToken[],
|
language?: string,
|
||||||
): number[] => {
|
): EpubCanonicalWordToken[] =>
|
||||||
const alignmentTokens = alignmentWords.map((word) => normalizeWordForHighlight(word.text));
|
segmentWords(segment.text, language)
|
||||||
const wordToToken = new Array<number>(alignmentWords.length).fill(-1);
|
.map((token) => ({
|
||||||
const m = alignmentTokens.length;
|
norm: normalizeWordForHighlight(token.text),
|
||||||
const n = segmentTokens.length;
|
sourceStart: segment.startAnchor.offset + token.start,
|
||||||
if (!m || !n) return wordToToken;
|
sourceEnd: segment.startAnchor.offset + token.end,
|
||||||
|
}))
|
||||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
|
.filter((token) => Boolean(token.norm));
|
||||||
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 buildWordHighlightCacheKey = (
|
export const buildWordHighlightCacheKey = (
|
||||||
segment: CanonicalTtsSegment,
|
segment: CanonicalTtsSegment,
|
||||||
alignment: TTSSentenceAlignment,
|
alignment: TTSSentenceAlignment,
|
||||||
|
language?: string,
|
||||||
): string =>
|
): string =>
|
||||||
[
|
[
|
||||||
segment.key,
|
segment.key,
|
||||||
segment.text.length,
|
segment.text.length,
|
||||||
|
language || '',
|
||||||
alignment.words.length,
|
alignment.words.length,
|
||||||
alignment.words.map((word) => normalizeWordForHighlight(word.text)).join('|'),
|
alignment.words.map((word) => normalizeWordForHighlight(word.text)).join('|'),
|
||||||
].join('::');
|
].join('::');
|
||||||
|
|
|
||||||
255
src/lib/client/highlight-token-alignment.ts
Normal file
255
src/lib/client/highlight-token-alignment.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -11,13 +11,18 @@
|
||||||
* sentence
|
* sentence
|
||||||
* - `WORD` — saturated background on the currently-spoken word
|
* - `WORD` — saturated background on the currently-spoken word
|
||||||
*
|
*
|
||||||
* Word-to-DOM alignment is done via Needleman-Wunsch (same approach the PDF
|
* Word-to-DOM alignment uses the shared viewer token-range mapper so DOM token
|
||||||
* reader uses) so DOM token counts that diverge from whisper's word count
|
* counts that diverge from the timed alignment still produce a smooth,
|
||||||
* still produce a smooth, monotonic word highlight rather than a proportional
|
* monotonic highlight across languages.
|
||||||
* approximation that snaps around when the counts disagree.
|
|
||||||
*/
|
*/
|
||||||
import { CmpStr } from 'cmpstr';
|
|
||||||
import type { TTSSentenceAlignment } from '@/types/tts';
|
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_SENTENCE_CLASS = 'openreader-html-highlight-sentence';
|
||||||
export const HTML_WORD_CLASS = 'openreader-html-highlight-word';
|
export const HTML_WORD_CLASS = 'openreader-html-highlight-word';
|
||||||
|
|
@ -29,8 +34,6 @@ interface DomToken {
|
||||||
norm: string;
|
norm: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
|
|
||||||
|
|
||||||
let sentenceWraps: HTMLSpanElement[] = [];
|
let sentenceWraps: HTMLSpanElement[] = [];
|
||||||
let wordWraps: HTMLSpanElement[] = [];
|
let wordWraps: HTMLSpanElement[] = [];
|
||||||
|
|
||||||
|
|
@ -45,29 +48,19 @@ interface SentenceState {
|
||||||
// is in place. Stable across word wrap/unwrap cycles because clear() calls
|
// is in place. Stable across word wrap/unwrap cycles because clear() calls
|
||||||
// `parent.normalize()` which restores the original text-node structure.
|
// `parent.normalize()` which restores the original text-node structure.
|
||||||
wordTokens: DomToken[];
|
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;
|
alignment: TTSSentenceAlignment | null;
|
||||||
wordToToken: number[] | null;
|
wordToTokenRange: Array<HighlightTokenRange | null> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sentenceState: SentenceState | null = null;
|
let sentenceState: SentenceState | null = null;
|
||||||
|
|
||||||
function normalizeWord(word: string): string {
|
function normalizeWord(word: string): string {
|
||||||
return word
|
return normalizeHighlightToken(word);
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[\p{P}\p{S}]+/gu, '')
|
|
||||||
.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tokenizePattern(pattern: string): string[] {
|
function tokenizePattern(pattern: string, language?: string): string[] {
|
||||||
const out: string[] = [];
|
return segmentWords(pattern, language).map((token) => normalizeWord(token.text)).filter(Boolean);
|
||||||
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 unwrap(span: HTMLSpanElement): void {
|
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);
|
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, {
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
||||||
acceptNode(node) {
|
acceptNode(node) {
|
||||||
const parent = node.parentElement;
|
const parent = node.parentElement;
|
||||||
|
|
@ -131,15 +128,13 @@ function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean
|
||||||
while (current) {
|
while (current) {
|
||||||
const textNode = current as Text;
|
const textNode = current as Text;
|
||||||
const text = textNode.nodeValue || '';
|
const text = textNode.nodeValue || '';
|
||||||
const wordRe = /\S+/g;
|
for (const token of segmentWords(text, language)) {
|
||||||
let m: RegExpExecArray | null;
|
const norm = normalizeWord(token.text);
|
||||||
while ((m = wordRe.exec(text)) !== null) {
|
|
||||||
const norm = normalizeWord(m[0]);
|
|
||||||
if (!norm) continue;
|
if (!norm) continue;
|
||||||
tokens.push({
|
tokens.push({
|
||||||
textNode,
|
textNode,
|
||||||
startOffset: m.index,
|
startOffset: token.start,
|
||||||
endOffset: m.index + m[0].length,
|
endOffset: token.end,
|
||||||
norm,
|
norm,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -153,7 +148,7 @@ function collectDomTokens(root: HTMLElement, opts: { skipHighlightWraps: boolean
|
||||||
* wrap is applied; lets us index just the words *within* the highlighted
|
* wrap is applied; lets us index just the words *within* the highlighted
|
||||||
* sentence rather than the whole document).
|
* sentence rather than the whole document).
|
||||||
*/
|
*/
|
||||||
function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
|
function collectTokensInsideWraps(wraps: HTMLSpanElement[], language?: string): DomToken[] {
|
||||||
const tokens: DomToken[] = [];
|
const tokens: DomToken[] = [];
|
||||||
for (const wrap of wraps) {
|
for (const wrap of wraps) {
|
||||||
const walker = document.createTreeWalker(wrap, NodeFilter.SHOW_TEXT);
|
const walker = document.createTreeWalker(wrap, NodeFilter.SHOW_TEXT);
|
||||||
|
|
@ -161,15 +156,13 @@ function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
|
||||||
while (current) {
|
while (current) {
|
||||||
const t = current as Text;
|
const t = current as Text;
|
||||||
const text = t.nodeValue || '';
|
const text = t.nodeValue || '';
|
||||||
const wordRe = /\S+/g;
|
for (const token of segmentWords(text, language)) {
|
||||||
let m: RegExpExecArray | null;
|
const norm = normalizeWord(token.text);
|
||||||
while ((m = wordRe.exec(text)) !== null) {
|
|
||||||
const norm = normalizeWord(m[0]);
|
|
||||||
if (!norm) continue;
|
if (!norm) continue;
|
||||||
tokens.push({
|
tokens.push({
|
||||||
textNode: t,
|
textNode: t,
|
||||||
startOffset: m.index,
|
startOffset: token.start,
|
||||||
endOffset: m.index + m[0].length,
|
endOffset: token.end,
|
||||||
norm,
|
norm,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -180,43 +173,9 @@ function collectTokensInsideWraps(wraps: HTMLSpanElement[]): DomToken[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
function findBestWindow(tokens: DomToken[], patternTokens: string[]): { start: number; end: number } | null {
|
function findBestWindow(tokens: DomToken[], patternTokens: string[]): { start: number; end: number } | null {
|
||||||
if (!tokens.length || !patternTokens.length) return null;
|
const match = findBestHighlightTokenMatch(patternTokens, tokens.map((token) => token.norm));
|
||||||
const pLen = patternTokens.length;
|
if (match.start === -1 || match.rating < 0.5) return null;
|
||||||
|
return { start: match.start, end: match.end };
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrapTokenRange(tokens: DomToken[], start: number, end: number, className: string): HTMLSpanElement[] {
|
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(
|
export function highlightHtmlSentence(
|
||||||
container: HTMLElement | null | undefined,
|
container: HTMLElement | null | undefined,
|
||||||
sentence: string | null | undefined,
|
sentence: string | null | undefined,
|
||||||
|
language?: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
clearHtmlSentenceHighlight();
|
clearHtmlSentenceHighlight();
|
||||||
if (!container || !sentence?.trim()) return false;
|
if (!container || !sentence?.trim()) return false;
|
||||||
|
|
||||||
const patternTokens = tokenizePattern(sentence);
|
const patternTokens = tokenizePattern(sentence, language);
|
||||||
if (!patternTokens.length) return false;
|
if (!patternTokens.length) return false;
|
||||||
|
|
||||||
const domTokens = collectDomTokens(container);
|
const domTokens = collectDomTokens(container, language);
|
||||||
if (!domTokens.length) return false;
|
if (!domTokens.length) return false;
|
||||||
|
|
||||||
const win = findBestWindow(domTokens, patternTokens);
|
const win = findBestWindow(domTokens, patternTokens);
|
||||||
|
|
@ -280,118 +240,13 @@ export function highlightHtmlSentence(
|
||||||
// can look up individual word tokens without re-walking the doc.
|
// can look up individual word tokens without re-walking the doc.
|
||||||
sentenceState = {
|
sentenceState = {
|
||||||
sentence,
|
sentence,
|
||||||
wordTokens: collectTokensInsideWraps(sentenceWraps),
|
wordTokens: collectTokensInsideWraps(sentenceWraps, language),
|
||||||
alignment: null,
|
alignment: null,
|
||||||
wordToToken: null,
|
wordToTokenRange: null,
|
||||||
};
|
};
|
||||||
return true;
|
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(
|
export function highlightHtmlWord(
|
||||||
container: HTMLElement | null | undefined,
|
container: HTMLElement | null | undefined,
|
||||||
alignment: TTSSentenceAlignment | undefined,
|
alignment: TTSSentenceAlignment | undefined,
|
||||||
|
|
@ -411,16 +266,20 @@ export function highlightHtmlWord(
|
||||||
if (!words.length || wordIndex >= words.length) return false;
|
if (!words.length || wordIndex >= words.length) return false;
|
||||||
|
|
||||||
// (Re)build the alignment map when this is a new alignment object.
|
// (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.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];
|
const tokenRange = sentenceState.wordToTokenRange[wordIndex];
|
||||||
if (tokenIndex === undefined || tokenIndex < 0) return false;
|
if (!tokenRange) return false;
|
||||||
if (tokenIndex >= sentenceState.wordTokens.length) 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;
|
return wordWraps.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
import { CmpStr } from 'cmpstr';
|
import { findBestHighlightTokenMatch } from './highlight-token-alignment';
|
||||||
|
|
||||||
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
|
|
||||||
|
|
||||||
interface TokenMatchRequest {
|
interface TokenMatchRequest {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'tokenMatch';
|
type: 'tokenMatch';
|
||||||
pattern: string;
|
patternTokens: string[];
|
||||||
tokenTexts: string[];
|
tokenTexts: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,111 +37,16 @@ self.onmessage = (event: MessageEvent<TokenMatchRequest>) => {
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
if (!data || data.type !== 'tokenMatch') return;
|
if (!data || data.type !== 'tokenMatch') return;
|
||||||
|
|
||||||
const { id, pattern, tokenTexts } = data;
|
const { id, patternTokens, tokenTexts } = data;
|
||||||
|
const result = findBestHighlightTokenMatch(patternTokens, tokenTexts);
|
||||||
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
|
|
||||||
const patternLen = cleanPattern.length;
|
|
||||||
|
|
||||||
const responseBase: TokenMatchResponse = {
|
|
||||||
id,
|
|
||||||
type: 'tokenMatchResult',
|
|
||||||
bestStart: -1,
|
|
||||||
bestEnd: -1,
|
|
||||||
rating: 0,
|
|
||||||
lengthDiff: Number.POSITIVE_INFINITY,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!patternLen || !tokenTexts.length) {
|
|
||||||
(self as unknown as DedicatedWorkerGlobalScope).postMessage(responseBase);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const patternTokens = cleanPattern.split(' ').filter(Boolean);
|
|
||||||
const patternTokenCount = patternTokens.length || 1;
|
|
||||||
|
|
||||||
const minWindowTokens = Math.max(1, Math.floor(patternTokenCount * 0.6));
|
|
||||||
const maxWindowTokens = Math.max(
|
|
||||||
minWindowTokens,
|
|
||||||
Math.ceil(patternTokenCount * 1.4)
|
|
||||||
);
|
|
||||||
|
|
||||||
let bestStart = -1;
|
|
||||||
let bestEnd = -1;
|
|
||||||
let bestRating = 0;
|
|
||||||
let bestLengthDiff = Number.POSITIVE_INFINITY;
|
|
||||||
|
|
||||||
for (let start = 0; start < tokenTexts.length; start++) {
|
|
||||||
let combined = '';
|
|
||||||
|
|
||||||
for (
|
|
||||||
let offset = 0;
|
|
||||||
offset < maxWindowTokens && start + offset < tokenTexts.length;
|
|
||||||
offset++
|
|
||||||
) {
|
|
||||||
const token = tokenTexts[start + offset];
|
|
||||||
combined = combined ? `${combined} ${token}` : token;
|
|
||||||
|
|
||||||
const windowSize = offset + 1;
|
|
||||||
if (windowSize < minWindowTokens) continue;
|
|
||||||
if (combined.length > patternLen * 2) break;
|
|
||||||
|
|
||||||
const similarity = cmp.compare(combined, cleanPattern);
|
|
||||||
const lengthDiff = Math.abs(combined.length - patternLen);
|
|
||||||
const lengthPenalty = lengthDiff / patternLen;
|
|
||||||
const adjustedRating = similarity * (1 - lengthPenalty * 0.3);
|
|
||||||
|
|
||||||
// Prefix-alignment boost:
|
|
||||||
// Favour windows whose first few tokens closely match the beginning
|
|
||||||
// of the pattern, so we are less likely to cut off the first 1–2 words.
|
|
||||||
let boostedRating = adjustedRating;
|
|
||||||
if (patternTokens.length > 0) {
|
|
||||||
const windowTokens = tokenTexts.slice(start, start + windowSize);
|
|
||||||
const maxPrefixCheck = Math.min(
|
|
||||||
windowTokens.length,
|
|
||||||
patternTokens.length,
|
|
||||||
5
|
|
||||||
);
|
|
||||||
|
|
||||||
let prefixMatches = 0;
|
|
||||||
for (let i = 0; i < maxPrefixCheck; i++) {
|
|
||||||
const tokenText = windowTokens[i];
|
|
||||||
const patternToken = patternTokens[i];
|
|
||||||
// Require reasonably strong similarity for a prefix match
|
|
||||||
const tokenSim = cmp.compare(tokenText, patternToken);
|
|
||||||
if (tokenSim >= 0.8) {
|
|
||||||
prefixMatches++;
|
|
||||||
} else {
|
|
||||||
// Stop at the first non-matching leading token
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prefixMatches > 0) {
|
|
||||||
const prefixRatio = prefixMatches / maxPrefixCheck;
|
|
||||||
const PREFIX_BOOST_FACTOR = 0.25; // up to +25% boost
|
|
||||||
boostedRating = adjustedRating * (1 + prefixRatio * PREFIX_BOOST_FACTOR);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
boostedRating > bestRating ||
|
|
||||||
(Math.abs(boostedRating - bestRating) < 1e-3 &&
|
|
||||||
lengthDiff < bestLengthDiff)
|
|
||||||
) {
|
|
||||||
bestRating = boostedRating;
|
|
||||||
bestLengthDiff = lengthDiff;
|
|
||||||
bestStart = start;
|
|
||||||
bestEnd = start + offset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: TokenMatchResponse = {
|
const response: TokenMatchResponse = {
|
||||||
...responseBase,
|
id,
|
||||||
bestStart,
|
type: 'tokenMatchResult',
|
||||||
bestEnd,
|
bestStart: result.start,
|
||||||
rating: bestRating,
|
bestEnd: result.end,
|
||||||
lengthDiff: bestLengthDiff,
|
rating: result.rating,
|
||||||
|
lengthDiff: result.lengthDiff,
|
||||||
};
|
};
|
||||||
|
|
||||||
(self as unknown as DedicatedWorkerGlobalScope).postMessage(response);
|
(self as unknown as DedicatedWorkerGlobalScope).postMessage(response);
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,18 @@ import { TextLayer } from 'pdfjs-dist';
|
||||||
import "core-js/proposals/promise-with-resolvers";
|
import "core-js/proposals/promise-with-resolvers";
|
||||||
import type { TTSSentenceAlignment } from '@/types/tts';
|
import type { TTSSentenceAlignment } from '@/types/tts';
|
||||||
import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf';
|
import type { ParsedPdfDocument, ParsedPdfPage } from '@/types/parsed-pdf';
|
||||||
import { CmpStr } from 'cmpstr';
|
|
||||||
import type { TTSSegmentLocator } from '@/types/client';
|
import type { TTSSegmentLocator } from '@/types/client';
|
||||||
|
import { segmentWords } from '@/lib/shared/language';
|
||||||
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
|
import {
|
||||||
|
buildAlignmentTokenRanges,
|
||||||
|
type HighlightTokenRange,
|
||||||
|
} from '@/lib/client/highlight-token-alignment';
|
||||||
|
|
||||||
// Worker coordination for offloading highlight token matching
|
// Worker coordination for offloading highlight token matching
|
||||||
interface HighlightTokenMatchRequest {
|
interface HighlightTokenMatchRequest {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'tokenMatch';
|
type: 'tokenMatch';
|
||||||
pattern: string;
|
patternTokens: string[];
|
||||||
tokenTexts: string[];
|
tokenTexts: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,7 +47,7 @@ function getHighlightWorker(): Worker | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
function runHighlightTokenMatch(
|
function runHighlightTokenMatch(
|
||||||
pattern: string,
|
patternTokens: string[],
|
||||||
tokenTexts: string[]
|
tokenTexts: string[]
|
||||||
): Promise<HighlightTokenMatchResponse | null> {
|
): Promise<HighlightTokenMatchResponse | null> {
|
||||||
const worker = getHighlightWorker();
|
const worker = getHighlightWorker();
|
||||||
|
|
@ -70,7 +72,7 @@ function runHighlightTokenMatch(
|
||||||
const message: HighlightTokenMatchRequest = {
|
const message: HighlightTokenMatchRequest = {
|
||||||
id,
|
id,
|
||||||
type: 'tokenMatch',
|
type: 'tokenMatch',
|
||||||
pattern,
|
patternTokens,
|
||||||
tokenTexts,
|
tokenTexts,
|
||||||
};
|
};
|
||||||
worker.postMessage(message);
|
worker.postMessage(message);
|
||||||
|
|
@ -151,7 +153,7 @@ let lastSpanNodes: HTMLElement[] = [];
|
||||||
let lastTokens: PDFToken[] = [];
|
let lastTokens: PDFToken[] = [];
|
||||||
let lastSentenceTokenWindow: { start: number; end: number } | null = null;
|
let lastSentenceTokenWindow: { start: number; end: number } | null = null;
|
||||||
let lastSentencePattern: string | null = null;
|
let lastSentencePattern: string | null = null;
|
||||||
let lastSentenceWordToTokenMap: number[] | null = null;
|
let lastSentenceWordToTokenRangeMap: Array<HighlightTokenRange | null> | null = null;
|
||||||
|
|
||||||
function getOrCreateHighlightLayer(span: HTMLElement): {
|
function getOrCreateHighlightLayer(span: HTMLElement): {
|
||||||
layer: HTMLElement;
|
layer: HTMLElement;
|
||||||
|
|
@ -180,12 +182,6 @@ function getOrCreateHighlightLayer(span: HTMLElement): {
|
||||||
return { layer, pageElement, pageRect: pageElement.getBoundingClientRect() };
|
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
|
// Highlighting functions
|
||||||
let highlightPatternSeq = 0;
|
let highlightPatternSeq = 0;
|
||||||
|
|
||||||
|
|
@ -193,6 +189,7 @@ type HighlightPatternOptions = {
|
||||||
parsedDocument?: ParsedPdfDocument | null;
|
parsedDocument?: ParsedPdfDocument | null;
|
||||||
locator?: TTSSegmentLocator | null;
|
locator?: TTSSegmentLocator | null;
|
||||||
useBlockGeometryOnly?: boolean;
|
useBlockGeometryOnly?: boolean;
|
||||||
|
language?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getHighlightLayerForPage(pageElement: HTMLElement): {
|
function getHighlightLayerForPage(pageElement: HTMLElement): {
|
||||||
|
|
@ -422,7 +419,7 @@ export function highlightPattern(
|
||||||
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
|
const cleanPattern = pattern.trim().replace(/\s+/g, ' ');
|
||||||
if (!cleanPattern) return;
|
if (!cleanPattern) return;
|
||||||
lastSentencePattern = cleanPattern;
|
lastSentencePattern = cleanPattern;
|
||||||
lastSentenceWordToTokenMap = null;
|
lastSentenceWordToTokenRangeMap = null;
|
||||||
lastSentenceTokenWindow = null;
|
lastSentenceTokenWindow = null;
|
||||||
const parsedDocument = options?.parsedDocument ?? null;
|
const parsedDocument = options?.parsedDocument ?? null;
|
||||||
const locator = options?.locator ?? null;
|
const locator = options?.locator ?? null;
|
||||||
|
|
@ -451,17 +448,13 @@ export function highlightPattern(
|
||||||
|
|
||||||
const textNode = node as Text;
|
const textNode = node as Text;
|
||||||
const textContent = textNode.textContent || '';
|
const textContent = textNode.textContent || '';
|
||||||
const wordRegex = /\S+/g;
|
for (const token of segmentWords(textContent, options?.language)) {
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = wordRegex.exec(textContent)) !== null) {
|
|
||||||
const word = match[0];
|
|
||||||
tokens.push({
|
tokens.push({
|
||||||
spanIndex,
|
spanIndex,
|
||||||
textNode,
|
textNode,
|
||||||
text: word,
|
text: token.text,
|
||||||
startOffset: match.index,
|
startOffset: token.start,
|
||||||
endOffset: match.index + word.length,
|
endOffset: token.end,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -617,9 +610,10 @@ export function highlightPattern(
|
||||||
};
|
};
|
||||||
|
|
||||||
const tokenTexts = tokens.map((t) => t.text);
|
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
|
// Fire-and-forget async worker call; UI thread returns immediately
|
||||||
runHighlightTokenMatch(cleanPattern, tokenTexts)
|
runHighlightTokenMatch(patternTokens, tokenTexts)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (seq !== highlightPatternSeq) return;
|
if (seq !== highlightPatternSeq) return;
|
||||||
if (!result || result.bestStart === -1) {
|
if (!result || result.bestStart === -1) {
|
||||||
|
|
@ -676,158 +670,61 @@ export function highlightWordIndex(
|
||||||
const end = lastSentenceTokenWindow.end;
|
const end = lastSentenceTokenWindow.end;
|
||||||
if (end < start) return;
|
if (end < start) return;
|
||||||
|
|
||||||
// Lazily build or refresh the mapping from alignment word
|
// Lazily build or refresh the shared mapping from alignment words to PDF
|
||||||
// indices to PDF token indices for this sentence window.
|
// token ranges for this sentence window.
|
||||||
if (
|
if (
|
||||||
!lastSentenceWordToTokenMap ||
|
!lastSentenceWordToTokenRangeMap ||
|
||||||
lastSentenceWordToTokenMap.length !== words.length
|
lastSentenceWordToTokenRangeMap.length !== words.length
|
||||||
) {
|
) {
|
||||||
const pdfFiltered: { tokenIndex: number; norm: string }[] = [];
|
const relativeRanges = buildAlignmentTokenRanges(
|
||||||
for (let i = start; i <= end; i++) {
|
words,
|
||||||
const norm = normalizeWordForMatch(lastTokens[i].text);
|
lastTokens.slice(start, end + 1).map((token) => token.text),
|
||||||
if (!norm) continue;
|
{ fillGaps: true },
|
||||||
pdfFiltered.push({ tokenIndex: i, norm });
|
);
|
||||||
}
|
lastSentenceWordToTokenRangeMap = relativeRanges.map((range) => (
|
||||||
|
range ? { start: range.start + start, end: range.end + start } : null
|
||||||
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 mappedIndex =
|
const tokenRange = lastSentenceWordToTokenRangeMap[wordIndex];
|
||||||
lastSentenceWordToTokenMap && wordIndex < lastSentenceWordToTokenMap.length
|
if (!tokenRange) return;
|
||||||
? lastSentenceWordToTokenMap[wordIndex]
|
|
||||||
: -1;
|
|
||||||
|
|
||||||
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];
|
try {
|
||||||
const span = lastSpanNodes[token.spanIndex];
|
const range = document.createRange();
|
||||||
if (!span) return;
|
range.setStart(node, token.startOffset);
|
||||||
|
range.setEnd(node, token.endOffset);
|
||||||
|
|
||||||
const node = token.textNode;
|
const highlightTarget = getOrCreateHighlightLayer(span);
|
||||||
if (!node || node.nodeType !== Node.TEXT_NODE) return;
|
if (!highlightTarget) continue;
|
||||||
|
|
||||||
try {
|
const { layer: highlightLayer, pageRect } = highlightTarget;
|
||||||
const range = document.createRange();
|
const rects = Array.from(range.getClientRects());
|
||||||
range.setStart(node, token.startOffset);
|
|
||||||
range.setEnd(node, token.endOffset);
|
|
||||||
|
|
||||||
const highlightTarget = getOrCreateHighlightLayer(span);
|
rects.forEach((rect) => {
|
||||||
if (!highlightTarget) return;
|
const highlight = document.createElement('div');
|
||||||
|
highlight.className = 'pdf-word-highlight-overlay';
|
||||||
const { layer: highlightLayer, pageRect } = highlightTarget;
|
highlight.style.position = 'absolute';
|
||||||
const rects = Array.from(range.getClientRects());
|
highlight.style.backgroundColor = 'var(--accent)';
|
||||||
|
highlight.style.opacity = '0.4';
|
||||||
rects.forEach((rect) => {
|
highlight.style.pointerEvents = 'none';
|
||||||
const highlight = document.createElement('div');
|
highlight.style.left = `${rect.left - pageRect.left}px`;
|
||||||
highlight.className = 'pdf-word-highlight-overlay';
|
highlight.style.top = `${rect.top - pageRect.top}px`;
|
||||||
highlight.style.position = 'absolute';
|
highlight.style.width = `${rect.width}px`;
|
||||||
highlight.style.backgroundColor = 'var(--accent)';
|
highlight.style.height = `${rect.height}px`;
|
||||||
highlight.style.opacity = '0.4';
|
highlight.style.zIndex = '2';
|
||||||
highlight.style.pointerEvents = 'none';
|
highlightLayer.appendChild(highlight);
|
||||||
highlight.style.left = `${rect.left - pageRect.left}px`;
|
});
|
||||||
highlight.style.top = `${rect.top - pageRect.top}px`;
|
} catch {
|
||||||
highlight.style.width = `${rect.width}px`;
|
// Ignore range errors
|
||||||
highlight.style.height = `${rect.height}px`;
|
}
|
||||||
highlight.style.zIndex = '2';
|
|
||||||
highlightLayer.appendChild(highlight);
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Ignore range errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { AudiobookGenerationSettings } from '@/types/client';
|
||||||
import type { TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||||
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
|
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
|
||||||
import { resolvePreferredSharedProviderSlug } from '@/lib/shared/shared-provider-selection';
|
import { resolvePreferredSharedProviderSlug } from '@/lib/shared/shared-provider-selection';
|
||||||
|
import { normalizeLanguageTag } from '@/lib/shared/language';
|
||||||
|
|
||||||
function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat {
|
function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat {
|
||||||
return value === 'mp3' || value === 'm4b';
|
return value === 'mp3' || value === 'm4b';
|
||||||
|
|
@ -68,6 +69,7 @@ export function coerceAudiobookGenerationSettings(
|
||||||
postSpeed,
|
postSpeed,
|
||||||
format,
|
format,
|
||||||
...(typeof record.ttsInstructions === 'string' ? { ttsInstructions: record.ttsInstructions } : {}),
|
...(typeof record.ttsInstructions === 'string' ? { ttsInstructions: record.ttsInstructions } : {}),
|
||||||
|
...(typeof record.language === 'string' ? { language: normalizeLanguageTag(record.language) } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const migrated =
|
const migrated =
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,16 @@ import {
|
||||||
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
|
||||||
} from '@/lib/shared/tts-provider-catalog';
|
} from '@/lib/shared/tts-provider-catalog';
|
||||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
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 { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { LRUCache } from 'lru-cache';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { access, readFile } from 'fs/promises';
|
import { access, readFile } from 'fs/promises';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
import { getLanguageDisplayName, toBaseLanguageCode } from '@/lib/shared/language';
|
||||||
|
|
||||||
export interface ServerTTSRequest {
|
export interface ServerTTSRequest {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -20,6 +24,7 @@ export interface ServerTTSRequest {
|
||||||
format?: string;
|
format?: string;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
|
language?: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
|
@ -30,6 +35,7 @@ type CustomVoice = string;
|
||||||
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
type ExtendedSpeechParams = Omit<SpeechCreateParams, 'voice'> & {
|
||||||
voice: SpeechCreateParams['voice'] | CustomVoice;
|
voice: SpeechCreateParams['voice'] | CustomVoice;
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
|
language?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResolvedServerTTSRequest = {
|
type ResolvedServerTTSRequest = {
|
||||||
|
|
@ -39,6 +45,7 @@ type ResolvedServerTTSRequest = {
|
||||||
format: string;
|
format: string;
|
||||||
model: SpeechCreateParams['model'];
|
model: SpeechCreateParams['model'];
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
|
language?: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
|
@ -55,6 +62,7 @@ const REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES = 512;
|
||||||
const replicateBlockedUntilByScope = new LRUCache<string, number>({
|
const replicateBlockedUntilByScope = new LRUCache<string, number>({
|
||||||
max: REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES,
|
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_MAX_SIZE_BYTES = 256 * 1024 * 1024;
|
||||||
const DEFAULT_TTS_CACHE_TTL_MS = 1000 * 60 * 30;
|
const DEFAULT_TTS_CACHE_TTL_MS = 1000 * 60 * 30;
|
||||||
|
|
@ -323,6 +331,7 @@ function resolveTTSRequest(input: ServerTTSRequest): ResolvedServerTTSRequest {
|
||||||
format,
|
format,
|
||||||
model,
|
model,
|
||||||
instructions,
|
instructions,
|
||||||
|
language: input.language,
|
||||||
provider,
|
provider,
|
||||||
apiKey: input.apiKey,
|
apiKey: input.apiKey,
|
||||||
baseUrl: input.baseUrl,
|
baseUrl: input.baseUrl,
|
||||||
|
|
@ -338,6 +347,7 @@ function makeCacheKey(input: {
|
||||||
format: string;
|
format: string;
|
||||||
text: string;
|
text: string;
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
|
language?: string;
|
||||||
testNamespace?: string | null;
|
testNamespace?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const canonical = {
|
const canonical = {
|
||||||
|
|
@ -348,6 +358,7 @@ function makeCacheKey(input: {
|
||||||
format: input.format,
|
format: input.format,
|
||||||
text: input.text,
|
text: input.text,
|
||||||
instructions: input.instructions || undefined,
|
instructions: input.instructions || undefined,
|
||||||
|
language: input.language || undefined,
|
||||||
testNamespace: input.testNamespace || undefined,
|
testNamespace: input.testNamespace || undefined,
|
||||||
};
|
};
|
||||||
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
||||||
|
|
@ -363,6 +374,7 @@ export function buildTTSCacheKey(request: ServerTTSRequest): string {
|
||||||
format: resolved.format,
|
format: resolved.format,
|
||||||
text: resolved.text,
|
text: resolved.text,
|
||||||
instructions: resolved.instructions,
|
instructions: resolved.instructions,
|
||||||
|
language: resolved.language,
|
||||||
testNamespace: resolved.testNamespace,
|
testNamespace: resolved.testNamespace,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -433,7 +445,7 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<R
|
||||||
if (request.instructions) {
|
if (request.instructions) {
|
||||||
input.prompt = request.instructions;
|
input.prompt = request.instructions;
|
||||||
}
|
}
|
||||||
return input;
|
return addReplicateLanguageInput(input, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model === 'minimax/speech-2.8-turbo') {
|
if (model === 'minimax/speech-2.8-turbo') {
|
||||||
|
|
@ -445,7 +457,7 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<R
|
||||||
if (request.speed !== 1) {
|
if (request.speed !== 1) {
|
||||||
input.speed = Math.max(0.5, Math.min(2.0, request.speed));
|
input.speed = Math.max(0.5, Math.min(2.0, request.speed));
|
||||||
}
|
}
|
||||||
return input;
|
return addReplicateLanguageInput(input, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model === 'qwen/qwen3-tts') {
|
if (model === 'qwen/qwen3-tts') {
|
||||||
|
|
@ -457,7 +469,7 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<R
|
||||||
if (request.instructions) {
|
if (request.instructions) {
|
||||||
input.style_instruction = request.instructions;
|
input.style_instruction = request.instructions;
|
||||||
}
|
}
|
||||||
return input;
|
return addReplicateLanguageInput(input, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (model === 'inworld/tts-1.5-mini') {
|
if (model === 'inworld/tts-1.5-mini') {
|
||||||
|
|
@ -469,7 +481,7 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<R
|
||||||
if (request.speed !== 1) {
|
if (request.speed !== 1) {
|
||||||
input.speaking_rate = request.speed;
|
input.speaking_rate = request.speed;
|
||||||
}
|
}
|
||||||
return input;
|
return addReplicateLanguageInput(input, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
const input: Record<string, unknown> = { text: request.text };
|
const input: Record<string, unknown> = { text: request.text };
|
||||||
|
|
@ -498,9 +510,46 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise<R
|
||||||
input.instructions = request.instructions;
|
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;
|
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(
|
async function runReplicateRequest(
|
||||||
request: ResolvedServerTTSRequest,
|
request: ResolvedServerTTSRequest,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
|
|
@ -594,6 +643,31 @@ async function runProviderRequest(
|
||||||
createParams.instructions = request.instructions;
|
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);
|
return fetchTTSBufferWithRetry(openai, createParams, signal, upstreamSettings.ttsUpstreamMaxRetries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -614,6 +688,7 @@ export async function generateTTSBuffer(
|
||||||
format: resolved.format,
|
format: resolved.format,
|
||||||
text: resolved.text,
|
text: resolved.text,
|
||||||
instructions: resolved.instructions,
|
instructions: resolved.instructions,
|
||||||
|
language: resolved.language,
|
||||||
testNamespace: resolved.testNamespace,
|
testNamespace: resolved.testNamespace,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
|
import { preprocessSentenceForAudio } from '@/lib/shared/nlp';
|
||||||
|
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
|
||||||
import { locatorIdentityKey } from '@/lib/shared/tts-locator';
|
import { locatorIdentityKey } from '@/lib/shared/tts-locator';
|
||||||
import { ffprobeAudio } from '@/lib/server/audiobooks/chapters';
|
import { ffprobeAudio } from '@/lib/server/audiobooks/chapters';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -32,6 +33,7 @@ function settingsCanonical(settings: TTSSegmentSettings): string {
|
||||||
voice: settings.voice,
|
voice: settings.voice,
|
||||||
speed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
|
speed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
|
||||||
instructions: settings.ttsInstructions || '',
|
instructions: settings.ttsInstructions || '',
|
||||||
|
language: settings.language || 'en',
|
||||||
format: 'mp3',
|
format: 'mp3',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -48,6 +50,7 @@ export function buildTtsSegmentSettingsJson(settings: TTSSegmentSettings): TTSSe
|
||||||
voice: settings.voice,
|
voice: settings.voice,
|
||||||
nativeSpeed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
|
nativeSpeed: Number.isFinite(Number(settings.nativeSpeed)) ? Number(settings.nativeSpeed) : 1,
|
||||||
ttsInstructions: settings.ttsInstructions || '',
|
ttsInstructions: settings.ttsInstructions || '',
|
||||||
|
language: settings.language || 'en',
|
||||||
};
|
};
|
||||||
// Postgres jsonb accepts the object directly; SQLite text needs a canonical JSON string.
|
// Postgres jsonb accepts the object directly; SQLite text needs a canonical JSON string.
|
||||||
return process.env.POSTGRES_URL ? canonical : settingsCanonical(settings);
|
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 }> {
|
function alignWordsToText(
|
||||||
const words = sentence.match(/\S+/g) || [];
|
sentence: string,
|
||||||
const aligned: Array<{ text: string; charStart: number; charEnd: number }> = [];
|
language?: string,
|
||||||
let cursor = 0;
|
): Array<{ text: string; charStart: number; charEnd: number }> {
|
||||||
const lowerSentence = sentence.toLowerCase();
|
return segmentWords(sentence, language).map((token) => ({
|
||||||
|
text: token.text,
|
||||||
for (const token of words) {
|
charStart: token.start,
|
||||||
const clean = token.trim();
|
charEnd: token.end,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildProportionalAlignment(input: {
|
export function buildProportionalAlignment(input: {
|
||||||
sentence: string;
|
sentence: string;
|
||||||
sentenceIndex: number;
|
sentenceIndex: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
|
language?: string;
|
||||||
}): TTSSentenceAlignment {
|
}): TTSSentenceAlignment {
|
||||||
const wordsWithOffsets = alignWordsToText(input.sentence);
|
const wordsWithOffsets = alignWordsToText(input.sentence, input.language);
|
||||||
if (wordsWithOffsets.length === 0 || input.durationMs <= 0) {
|
if (wordsWithOffsets.length === 0 || input.durationMs <= 0) {
|
||||||
return {
|
return {
|
||||||
sentence: input.sentence,
|
sentence: input.sentence,
|
||||||
|
|
@ -281,7 +273,7 @@ export function buildProportionalAlignment(input: {
|
||||||
|
|
||||||
const weighted = wordsWithOffsets.map((word) => ({
|
const weighted = wordsWithOffsets.map((word) => ({
|
||||||
...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);
|
const totalWeight = weighted.reduce((sum, word) => sum + word.weight, 0);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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_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(
|
const REPLICATE_BUILT_IN_MODELS = new Set(
|
||||||
resolveProviderModels('replicate')
|
resolveProviderModels('replicate')
|
||||||
.map((model) => model.id)
|
.map((model) => model.id)
|
||||||
|
|
@ -150,6 +163,26 @@ function extractReplicateVoiceInputKeyFromOpenApiSchema(openApiSchema: unknown):
|
||||||
return found;
|
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> {
|
async function fetchReplicateOpenApiSchema(apiKey: string, model: string): Promise<unknown | null> {
|
||||||
const parsedModel = parseReplicateModelIdentifier(model);
|
const parsedModel = parseReplicateModelIdentifier(model);
|
||||||
if (!parsedModel) {
|
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_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 REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES = 128;
|
||||||
|
|
||||||
const replicateVoiceInputKeyCache = new LRUCache<string, ReplicateVoiceInputKey>({
|
const replicateVoiceInputKeyCache = new LRUCache<string, ReplicateVoiceInputKey>({
|
||||||
max: REPLICATE_VOICE_INPUT_KEY_CACHE_MAX_ENTRIES,
|
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>>({
|
const replicateOpenApiSchemaPromiseCache = new LRUCache<string, Promise<unknown | null>>({
|
||||||
max: REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES,
|
max: REPLICATE_OPENAPI_SCHEMA_PROMISE_CACHE_MAX_ENTRIES,
|
||||||
});
|
});
|
||||||
|
|
@ -260,6 +297,33 @@ export async function resolveReplicateVoiceInputKey({
|
||||||
return inputKey;
|
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[]> {
|
async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
type DocumentSettings,
|
type DocumentSettings,
|
||||||
} from '@/types/document-settings';
|
} from '@/types/document-settings';
|
||||||
import { PARSED_PDF_BLOCK_KINDS, type ParsedPdfBlockKind } from '@/types/parsed-pdf';
|
import { PARSED_PDF_BLOCK_KINDS, type ParsedPdfBlockKind } from '@/types/parsed-pdf';
|
||||||
|
import { normalizeLanguageTag } from '@/lib/shared/language';
|
||||||
|
|
||||||
function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] {
|
function normalizeSkipKinds(value: unknown): ParsedPdfBlockKind[] {
|
||||||
if (!Array.isArray(value)) return [...(DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? [])];
|
if (!Array.isArray(value)) return [...(DEFAULT_DOCUMENT_SETTINGS.pdf?.skipBlockKinds ?? [])];
|
||||||
|
|
@ -22,6 +23,7 @@ export function mergeDocumentSettings(
|
||||||
): DocumentSettings {
|
): DocumentSettings {
|
||||||
const base: DocumentSettings = {
|
const base: DocumentSettings = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
language: defaults.language || 'auto',
|
||||||
pdf: {
|
pdf: {
|
||||||
skipBlockKinds: [...(defaults.pdf?.skipBlockKinds ?? [])],
|
skipBlockKinds: [...(defaults.pdf?.skipBlockKinds ?? [])],
|
||||||
},
|
},
|
||||||
|
|
@ -29,12 +31,17 @@ export function mergeDocumentSettings(
|
||||||
|
|
||||||
if (!stored || typeof stored !== 'object') return base;
|
if (!stored || typeof stored !== 'object') return base;
|
||||||
const rec = stored as Record<string, unknown>;
|
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;
|
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>;
|
const pdfRec = pdf as Record<string, unknown>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
language,
|
||||||
pdf: {
|
pdf: {
|
||||||
skipBlockKinds: normalizeSkipKinds(pdfRec.skipBlockKinds),
|
skipBlockKinds: normalizeSkipKinds(pdfRec.skipBlockKinds),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
199
src/lib/shared/language.ts
Normal file
199
src/lib/shared/language.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,14 @@
|
||||||
* It handles text preprocessing, sentence splitting, and block creation for optimal TTS processing.
|
* 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;
|
export const MAX_BLOCK_LENGTH = 450;
|
||||||
const MIN_BLOCK_LENGTH = 50;
|
const MIN_BLOCK_LENGTH = 50;
|
||||||
|
|
||||||
export interface TtsSplitOptions {
|
export interface TtsSplitOptions {
|
||||||
maxBlockLength?: number;
|
maxBlockLength?: number;
|
||||||
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMaxBlockLength(options?: TtsSplitOptions): number {
|
function resolveMaxBlockLength(options?: TtsSplitOptions): number {
|
||||||
|
|
@ -27,9 +28,9 @@ const splitOversizedText = (text: string, maxLen: number): string[] => {
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
const MAX_OVERFLOW = maxLen; // allow finishing the sentence up to +maxLen chars
|
const MAX_OVERFLOW = maxLen; // allow finishing the sentence up to +maxLen chars
|
||||||
const CLOSERS = new Set(['"', "'", '”', '’', ')', ']', '}']);
|
const CLOSERS = new Set(['"', "'", '”', '’', ')', ']', '}', '」', '』', '】']);
|
||||||
const BREAK_CHARS = new Set(['.', '!', '?']);
|
const BREAK_CHARS = new Set(['.', '!', '?', '。', '!', '?', '؟', '।']);
|
||||||
const SOFT_BREAK_CHARS = new Set([';', ':']);
|
const SOFT_BREAK_CHARS = new Set([';', ':', ';', ':']);
|
||||||
|
|
||||||
const findPunctuationCut = (s: string, limit: number): number | null => {
|
const findPunctuationCut = (s: string, limit: number): number | null => {
|
||||||
for (let i = limit; i >= 0; i--) {
|
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
|
// Allow a boundary at end/whitespace, or common PDF artifact where
|
||||||
// the next sentence starts immediately with an uppercase letter.
|
// 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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
@ -74,7 +75,7 @@ const splitOversizedText = (text: string, maxLen: number): string[] => {
|
||||||
while (cut < s.length && CLOSERS.has(s[cut])) cut++;
|
while (cut < s.length && CLOSERS.has(s[cut])) cut++;
|
||||||
const after = cut < s.length ? s[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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
@ -87,7 +88,7 @@ const splitOversizedText = (text: string, maxLen: number): string[] => {
|
||||||
let end = i + 1;
|
let end = i + 1;
|
||||||
while (end < s.length && CLOSERS.has(s[end])) end++;
|
while (end < s.length && CLOSERS.has(s[end])) end++;
|
||||||
const after = end < s.length ? s[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;
|
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,
|
// Insert a space only when it looks like a sentence boundary (lower/digit before,
|
||||||
// uppercase after) to avoid breaking abbreviations like "U.S.A".
|
// uppercase after) to avoid breaking abbreviations like "U.S.A".
|
||||||
return text
|
return text
|
||||||
.replace(/([a-z0-9])([.!?])(?=[A-Z])/g, '$1$2 ')
|
.replace(/([\p{Ll}\p{N}])([.!?])(?=\p{Lu})/gu, '$1$2 ')
|
||||||
.replace(/([a-z0-9][.!?][\"”’)\]])(?=[A-Z])/g, '$1 ');
|
.replace(/([\p{Ll}\p{N}][.!?]["”’)\]])(?=\p{Lu})/gu, '$1 ');
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -136,7 +137,7 @@ const normalizeSentenceBoundariesForNlp = (text: string): string => {
|
||||||
export const preprocessSentenceForAudio = (text: string): string => {
|
export const preprocessSentenceForAudio = (text: string): string => {
|
||||||
return text
|
return text
|
||||||
.replace(/\S*(?:https?:\/\/|www\.)([^\/\s]+)(?:\/\S*)?/gi, '- (link to $1) -')
|
.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 *
|
// Remove special character *
|
||||||
.replace(/\*/g, '')
|
.replace(/\*/g, '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
|
|
@ -151,6 +152,8 @@ export const preprocessSentenceForAudio = (text: string): string => {
|
||||||
*/
|
*/
|
||||||
export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): string[] => {
|
export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): string[] => {
|
||||||
const maxBlockLength = resolveMaxBlockLength(options);
|
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
|
// Treat double-newlines as paragraph boundaries; single newlines are usually
|
||||||
// just PDF line wrapping and should not force sentence/block boundaries.
|
// just PDF line wrapping and should not force sentence/block boundaries.
|
||||||
const paragraphs = text.split(/\n{2,}/);
|
const paragraphs = text.split(/\n{2,}/);
|
||||||
|
|
@ -162,8 +165,7 @@ export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): s
|
||||||
const cleanedText = normalizeSentenceBoundariesForNlp(
|
const cleanedText = normalizeSentenceBoundariesForNlp(
|
||||||
preprocessSentenceForAudio(paragraph)
|
preprocessSentenceForAudio(paragraph)
|
||||||
);
|
);
|
||||||
const doc = nlp(cleanedText);
|
const rawSentences = segmentSentences(cleanedText, language);
|
||||||
const rawSentences = doc.sentences().out('array') as string[];
|
|
||||||
|
|
||||||
// Merge multi-sentence dialogue enclosed in quotes into single items
|
// Merge multi-sentence dialogue enclosed in quotes into single items
|
||||||
const mergedSentences = mergeQuotedDialogue(rawSentences);
|
const mergedSentences = mergeQuotedDialogue(rawSentences);
|
||||||
|
|
@ -180,7 +182,7 @@ export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): s
|
||||||
currentBlock = sentencePart;
|
currentBlock = sentencePart;
|
||||||
} else {
|
} else {
|
||||||
currentBlock = currentBlock
|
currentBlock = currentBlock
|
||||||
? `${currentBlock} ${sentencePart}`
|
? `${currentBlock}${blockSeparator}${sentencePart}`
|
||||||
: sentencePart;
|
: sentencePart;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -200,6 +202,8 @@ export const splitTextToTtsBlocks = (text: string, options?: TtsSplitOptions): s
|
||||||
*/
|
*/
|
||||||
export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions): string[] => {
|
export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions): string[] => {
|
||||||
const maxBlockLength = resolveMaxBlockLength(options);
|
const maxBlockLength = resolveMaxBlockLength(options);
|
||||||
|
const language = normalizeLanguageTag(options?.language);
|
||||||
|
const blockSeparator = ['ja', 'zh'].includes(toBaseLanguageCode(language)) ? '' : ' ';
|
||||||
const paragraphs = text.split(/\n+/);
|
const paragraphs = text.split(/\n+/);
|
||||||
const blocks: string[] = [];
|
const blocks: string[] = [];
|
||||||
|
|
||||||
|
|
@ -207,8 +211,7 @@ export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions
|
||||||
if (!paragraph.trim()) continue;
|
if (!paragraph.trim()) continue;
|
||||||
|
|
||||||
const cleanedText = preprocessSentenceForAudio(paragraph);
|
const cleanedText = preprocessSentenceForAudio(paragraph);
|
||||||
const doc = nlp(cleanedText);
|
const rawSentences = segmentSentences(cleanedText, language);
|
||||||
const rawSentences = doc.sentences().out('array') as string[];
|
|
||||||
|
|
||||||
const mergedSentences = mergeQuotedDialogue(rawSentences);
|
const mergedSentences = mergeQuotedDialogue(rawSentences);
|
||||||
|
|
||||||
|
|
@ -227,7 +230,7 @@ export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions
|
||||||
currentBlock = sentencePart;
|
currentBlock = sentencePart;
|
||||||
} else {
|
} else {
|
||||||
currentBlock = currentBlock
|
currentBlock = currentBlock
|
||||||
? `${currentBlock} ${sentencePart}`
|
? `${currentBlock}${blockSeparator}${sentencePart}`
|
||||||
: sentencePart;
|
: sentencePart;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -248,8 +251,11 @@ export const splitTextToTtsBlocksEPUB = (text: string, options?: TtsSplitOptions
|
||||||
* @param {string} text - The text to process
|
* @param {string} text - The text to process
|
||||||
* @returns {string} Normalized text
|
* @returns {string} Normalized text
|
||||||
*/
|
*/
|
||||||
export const normalizeTextForTts = (text: string, options?: TtsSplitOptions): string =>
|
export const normalizeTextForTts = (text: string, options?: TtsSplitOptions): string => {
|
||||||
splitTextToTtsBlocks(text, options).join(' ');
|
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
|
// Helper functions to merge quoted dialogue across sentences
|
||||||
const countDoubleQuotes = (s: string): number => {
|
const countDoubleQuotes = (s: string): number => {
|
||||||
|
|
@ -265,8 +271,8 @@ const countNonApostropheSingleQuotes = (s: string): number => {
|
||||||
if (ch === "'" || ch === '‘' || ch === '’') {
|
if (ch === "'" || ch === '‘' || ch === '’') {
|
||||||
const prev = i > 0 ? s[i - 1] : '';
|
const prev = i > 0 ? s[i - 1] : '';
|
||||||
const next = i + 1 < s.length ? s[i + 1] : '';
|
const next = i + 1 < s.length ? s[i + 1] : '';
|
||||||
const isPrevAlphaNum = /[A-Za-z0-9]/.test(prev);
|
const isPrevAlphaNum = /[\p{L}\p{N}\p{M}]/u.test(prev);
|
||||||
const isNextAlphaNum = /[A-Za-z0-9]/.test(next);
|
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
|
// Treat as a real quote mark only when it's not clearly an apostrophe
|
||||||
// between two alphanumeric characters (e.g., don't, WizardLM’s).
|
// between two alphanumeric characters (e.g., don't, WizardLM’s).
|
||||||
if (!(isPrevAlphaNum && isNextAlphaNum)) {
|
if (!(isPrevAlphaNum && isNextAlphaNum)) {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { preprocessSentenceForAudio, splitTextToTtsBlocks, splitTextToTtsBlocksE
|
||||||
import type { TTSSegmentLocator } from '@/types/client';
|
import type { TTSSegmentLocator } from '@/types/client';
|
||||||
import type { ReaderType } from '@/types/user-state';
|
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 {
|
export interface CanonicalTtsSourceUnit {
|
||||||
sourceKey: string;
|
sourceKey: string;
|
||||||
|
|
@ -38,6 +38,7 @@ export interface CanonicalTtsSegmentPlanOptions {
|
||||||
maxBlockLength?: number;
|
maxBlockLength?: number;
|
||||||
keyPrefix?: string;
|
keyPrefix?: string;
|
||||||
enforceSourceBoundaries?: boolean;
|
enforceSourceBoundaries?: boolean;
|
||||||
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreparedSourceUnit {
|
interface PreparedSourceUnit {
|
||||||
|
|
@ -215,7 +216,10 @@ export function planCanonicalTtsSegments(
|
||||||
}
|
}
|
||||||
|
|
||||||
const canonicalText = textParts.join('');
|
const canonicalText = textParts.join('');
|
||||||
const splitOptions = { maxBlockLength: options.maxBlockLength };
|
const splitOptions = {
|
||||||
|
maxBlockLength: options.maxBlockLength,
|
||||||
|
language: options.language,
|
||||||
|
};
|
||||||
const splitIntoBlocks = (text: string): string[] =>
|
const splitIntoBlocks = (text: string): string[] =>
|
||||||
readerType === 'epub'
|
readerType === 'epub'
|
||||||
? splitTextToTtsBlocksEPUB(text, splitOptions)
|
? splitTextToTtsBlocksEPUB(text, splitOptions)
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export interface AudiobookGenerationSettings {
|
||||||
postSpeed: number;
|
postSpeed: number;
|
||||||
format: TTSAudiobookFormat;
|
format: TTSAudiobookFormat;
|
||||||
ttsInstructions?: string;
|
ttsInstructions?: string;
|
||||||
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateChapterPayload {
|
export interface CreateChapterPayload {
|
||||||
|
|
@ -70,6 +71,7 @@ export interface TTSSegmentSettings {
|
||||||
voice: string;
|
voice: string;
|
||||||
nativeSpeed: number;
|
nativeSpeed: number;
|
||||||
ttsInstructions?: string;
|
ttsInstructions?: string;
|
||||||
|
language?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TTSReaderType = 'pdf' | 'epub' | 'html';
|
type TTSReaderType = 'pdf' | 'epub' | 'html';
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { ParsedPdfBlockKind } from '@/types/parsed-pdf';
|
||||||
|
|
||||||
export interface DocumentSettings {
|
export interface DocumentSettings {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
|
language?: string;
|
||||||
pdf?: {
|
pdf?: {
|
||||||
skipBlockKinds: ParsedPdfBlockKind[];
|
skipBlockKinds: ParsedPdfBlockKind[];
|
||||||
};
|
};
|
||||||
|
|
@ -9,6 +10,7 @@ export interface DocumentSettings {
|
||||||
|
|
||||||
export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
|
export const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
language: 'auto',
|
||||||
pdf: {
|
pdf: {
|
||||||
skipBlockKinds: ['header', 'footer', 'footnote', 'vision_footnote'],
|
skipBlockKinds: ['header', 'footer', 'footnote', 'vision_footnote'],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
23
tests/files/multilingual-sample.txt
Normal file
23
tests/files/multilingual-sample.txt
Normal 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 ควรแบ่งประโยคนี้อย่างถูกต้อง นี่คือประโยคที่สอง
|
||||||
|
|
@ -15,6 +15,7 @@ describe('coerceAudiobookGenerationSettings', () => {
|
||||||
postSpeed: 1,
|
postSpeed: 1,
|
||||||
format: 'mp3',
|
format: 'mp3',
|
||||||
ttsInstructions: 'keep calm',
|
ttsInstructions: 'keep calm',
|
||||||
|
language: 'ja-jp',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.migrated).toBe(false);
|
expect(result.migrated).toBe(false);
|
||||||
|
|
@ -27,6 +28,7 @@ describe('coerceAudiobookGenerationSettings', () => {
|
||||||
postSpeed: 1,
|
postSpeed: 1,
|
||||||
format: 'mp3',
|
format: 'mp3',
|
||||||
ttsInstructions: 'keep calm',
|
ttsInstructions: 'keep calm',
|
||||||
|
language: 'ja-JP',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -93,6 +95,7 @@ describe('coerceAudiobookGenerationSettings', () => {
|
||||||
postSpeed: 1,
|
postSpeed: 1,
|
||||||
format: 'mp3',
|
format: 'mp3',
|
||||||
ttsInstructions: '',
|
ttsInstructions: '',
|
||||||
|
language: 'en',
|
||||||
},
|
},
|
||||||
restrictUserApiKeys: true,
|
restrictUserApiKeys: true,
|
||||||
fallbackProviderRef: 'shared-openai',
|
fallbackProviderRef: 'shared-openai',
|
||||||
|
|
@ -111,5 +114,6 @@ describe('coerceAudiobookGenerationSettings', () => {
|
||||||
expect(settings.providerType).toBe('openai');
|
expect(settings.providerType).toBe('openai');
|
||||||
expect(settings.ttsModel).toBe('gpt-4o-mini-tts');
|
expect(settings.ttsModel).toBe('gpt-4o-mini-tts');
|
||||||
expect(settings.ttsInstructions).toBe('Speak with warmth.');
|
expect(settings.ttsInstructions).toBe('Speak with warmth.');
|
||||||
|
expect(settings.language).toBe('en');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
22
tests/unit/document-settings.vitest.spec.ts
Normal file
22
tests/unit/document-settings.vitest.spec.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildMonotonicWordToTokenMap,
|
resolveAlignmentWordSourceRange,
|
||||||
tokenizeCanonicalSegment,
|
tokenizeCanonicalSegment,
|
||||||
} from '../../src/lib/client/epub/epub-word-highlight';
|
} from '../../src/lib/client/epub/epub-word-highlight';
|
||||||
import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan';
|
import type { CanonicalTtsSegment } from '../../src/lib/shared/tts-segment-plan';
|
||||||
|
|
@ -18,16 +18,46 @@ const segment = (text: string, offset = 0): CanonicalTtsSegment => ({
|
||||||
spansSourceBoundary: false,
|
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', () => {
|
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', () => {
|
test('tokenizes canonical segment words with source offsets', () => {
|
||||||
const tokens = tokenizeCanonicalSegment(segment('"Hello," she said.', 12));
|
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]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
107
tests/unit/highlight-token-alignment.vitest.spec.ts
Normal file
107
tests/unit/highlight-token-alignment.vitest.spec.ts
Normal 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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
124
tests/unit/language.vitest.spec.ts
Normal file
124
tests/unit/language.vitest.spec.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -46,6 +46,20 @@ describe('preprocessSentenceForAudio', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('splitTextToTtsBlocks (PDF-oriented)', () => {
|
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', () => {
|
test('returns [] for empty input', () => {
|
||||||
expect(splitTextToTtsBlocks('')).toEqual([]);
|
expect(splitTextToTtsBlocks('')).toEqual([]);
|
||||||
expect(splitTextToTtsBlocks(' ')).toEqual([]);
|
expect(splitTextToTtsBlocks(' ')).toEqual([]);
|
||||||
|
|
@ -201,4 +215,11 @@ describe('normalizeTextForTts', () => {
|
||||||
expect(normalized).not.toMatch(/\s{2,}/);
|
expect(normalized).not.toMatch(/\s{2,}/);
|
||||||
expect(normalized.length).toBeGreaterThan(0);
|
expect(normalized.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not insert spaces between normalized Japanese blocks', () => {
|
||||||
|
expect(normalizeTextForTts('最初の文です。次の文です。', {
|
||||||
|
language: 'ja',
|
||||||
|
maxBlockLength: 7,
|
||||||
|
})).toBe('最初の文です。次の文です。');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
43
tests/unit/shared-form-control-consumers.vitest.spec.ts
Normal file
43
tests/unit/shared-form-control-consumers.vitest.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
31
tests/unit/shared-overlay-consumers.vitest.spec.ts
Normal file
31
tests/unit/shared-overlay-consumers.vitest.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
31
tests/unit/shared-select-consumers.vitest.spec.ts
Normal file
31
tests/unit/shared-select-consumers.vitest.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { describe, expect, test } from 'vitest';
|
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', () => {
|
describe('replicate output URL extraction', () => {
|
||||||
test('returns direct URL string output', () => {
|
test('returns direct URL string output', () => {
|
||||||
|
|
@ -41,3 +45,29 @@ describe('replicate output URL extraction', () => {
|
||||||
expect(extractReplicateAudioUrl(output)).toBeNull();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ import {
|
||||||
supportsTtsInstructions,
|
supportsTtsInstructions,
|
||||||
} from '../../src/lib/shared/tts-provider-catalog';
|
} from '../../src/lib/shared/tts-provider-catalog';
|
||||||
import { normalizeLegacyProviderRef, resolveProviderDefaults, resolveTtsModelForProvider } from '../../src/lib/shared/tts-provider-policy';
|
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 { applyConfigUpdate, getVoicePreferenceKey } from '../../src/lib/client/config/updates';
|
||||||
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
|
import { buildSyncedPreferencePatch } from '../../src/lib/client/config/preferences';
|
||||||
|
|
||||||
|
|
@ -387,6 +391,50 @@ describe('tts provider catalog', () => {
|
||||||
globalThis.fetch = originalFetch;
|
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', () => {
|
describe('config helpers', () => {
|
||||||
|
|
|
||||||
|
|
@ -195,4 +195,19 @@ describe('tts segment helpers', () => {
|
||||||
expect(alignment.words[2].endSec).toBeGreaterThan(1.4);
|
expect(alignment.words[2].endSec).toBeGreaterThan(1.4);
|
||||||
expect(alignment.words[1].charStart).toBeGreaterThan(alignment.words[0].charStart);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue