feat(tts): add multilingual reader foundation
This commit is contained in:
parent
328dbbbb62
commit
07e27430c2
34 changed files with 590 additions and 132 deletions
|
|
@ -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';
|
||||||
|
|
||||||
|
|
@ -37,7 +38,8 @@ export default function EPUBPage() {
|
||||||
regenerateChapter: regenerateEPUBChapter,
|
regenerateChapter: regenerateEPUBChapter,
|
||||||
bookRef,
|
bookRef,
|
||||||
} = 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 +108,10 @@ export default function EPUBPage() {
|
||||||
|
|
||||||
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 = () => {
|
||||||
|
|
@ -249,6 +255,10 @@ 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}
|
||||||
|
onLanguageChange={(nextLanguage) => {
|
||||||
|
void updateLanguage(nextLanguage);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<SegmentsSidebar
|
<SegmentsSidebar
|
||||||
isOpen={activeSidebar === 'segments'}
|
isOpen={activeSidebar === 'segments'}
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,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,
|
||||||
|
|
@ -145,6 +154,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
currentWordHighlightCfiRef: currentWordHighlightCfi,
|
currentWordHighlightCfiRef: currentWordHighlightCfi,
|
||||||
renderedTextMapsRef,
|
renderedTextMapsRef,
|
||||||
wordHighlightMapCacheRef,
|
wordHighlightMapCacheRef,
|
||||||
|
language: resolvedLanguage,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -674,6 +679,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;
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,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' ? rec.language : '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 {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ 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 { 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, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
|
|
@ -50,7 +51,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 +126,12 @@ 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 fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
||||||
if (soft) {
|
if (soft) {
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,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 +106,13 @@ function RangeSetting({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentSettings({ isOpen, setIsOpen, epub, html, pdf }: {
|
export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, onLanguageChange, pdf }: {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
setIsOpen: (isOpen: boolean) => void,
|
setIsOpen: (isOpen: boolean) => void,
|
||||||
epub?: boolean,
|
epub?: boolean,
|
||||||
html?: boolean,
|
html?: boolean,
|
||||||
|
language?: string,
|
||||||
|
onLanguageChange?: (language: string) => void,
|
||||||
pdf?: {
|
pdf?: {
|
||||||
parseStatus: PdfParseStatus | null;
|
parseStatus: PdfParseStatus | null;
|
||||||
parsedOverlayEnabled: boolean;
|
parsedOverlayEnabled: boolean;
|
||||||
|
|
@ -151,6 +167,30 @@ 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"
|
||||||
|
>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="block text-[11px] font-semibold uppercase tracking-wide text-muted">
|
||||||
|
Document language
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
value={language}
|
||||||
|
onChange={(event) => onLanguageChange(event.target.value)}
|
||||||
|
className="w-full rounded-md border border-offbase bg-surface-solid px-3 py-2 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
{DOCUMENT_LANGUAGE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
{isPdfMode && pdf && (
|
{isPdfMode && pdf && (
|
||||||
<Section
|
<Section
|
||||||
title="PDF Essentials"
|
title="PDF Essentials"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,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 +53,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,9 +118,9 @@ 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 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,
|
||||||
|
|
@ -162,6 +164,7 @@ export function useEPUBHighlighting({
|
||||||
renderedTextMapsRef,
|
renderedTextMapsRef,
|
||||||
renditionRef,
|
renditionRef,
|
||||||
wordHighlightMapCacheRef,
|
wordHighlightMapCacheRef,
|
||||||
|
language,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const setRenderedTextMaps = useCallback((maps: EpubRenderedTextMap[]) => {
|
const setRenderedTextMaps = useCallback((maps: EpubRenderedTextMap[]) => {
|
||||||
|
|
|
||||||
52
src/hooks/useDocumentLanguage.ts
Normal file
52
src/hooks/useDocumentLanguage.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { getDocumentSettings, putDocumentSettings } from '@/lib/client/api/documents';
|
||||||
|
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
|
||||||
|
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
|
||||||
|
|
||||||
|
export function useDocumentLanguage(documentId: string | undefined): {
|
||||||
|
language: string;
|
||||||
|
updateLanguage: (language: string) => Promise<void>;
|
||||||
|
} {
|
||||||
|
const [settings, setSettings] = useState<DocumentSettings>(DEFAULT_DOCUMENT_SETTINGS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSettings(DEFAULT_DOCUMENT_SETTINGS);
|
||||||
|
if (!documentId) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
void getDocumentSettings(documentId, { signal: controller.signal })
|
||||||
|
.then((response) => {
|
||||||
|
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||||
|
console.warn('Failed to load document language, using automatic detection:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [documentId]);
|
||||||
|
|
||||||
|
const updateLanguage = useCallback(async (language: string): Promise<void> => {
|
||||||
|
if (!documentId) return;
|
||||||
|
const next = mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, {
|
||||||
|
...settings,
|
||||||
|
schemaVersion: 1,
|
||||||
|
language,
|
||||||
|
});
|
||||||
|
setSettings(next);
|
||||||
|
try {
|
||||||
|
const response = await putDocumentSettings(documentId, next);
|
||||||
|
setSettings(mergeDocumentSettings(DEFAULT_DOCUMENT_SETTINGS, response.settings));
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to persist document language:', error);
|
||||||
|
}
|
||||||
|
}, [documentId, settings]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
language: settings.language ?? 'auto',
|
||||||
|
updateLanguage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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,6 @@
|
||||||
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 } from '@/types/tts';
|
||||||
|
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
|
||||||
|
|
||||||
export type EpubCanonicalWordToken = {
|
export type EpubCanonicalWordToken = {
|
||||||
norm: string;
|
norm: string;
|
||||||
|
|
@ -8,35 +9,19 @@ export type EpubCanonicalWordToken = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizeWordForHighlight = (text: string): string =>
|
export const normalizeWordForHighlight = (text: string): string =>
|
||||||
text
|
normalizeUnicodeToken(text);
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9]+/g, '');
|
|
||||||
|
|
||||||
export const tokenizeCanonicalSegment = (segment: CanonicalTtsSegment): EpubCanonicalWordToken[] => {
|
export const tokenizeCanonicalSegment = (
|
||||||
const tokens: EpubCanonicalWordToken[] = [];
|
segment: CanonicalTtsSegment,
|
||||||
const wordRegex = /\S+/g;
|
language?: string,
|
||||||
let match: RegExpExecArray | null;
|
): EpubCanonicalWordToken[] =>
|
||||||
|
segmentWords(segment.text, language)
|
||||||
while ((match = wordRegex.exec(segment.text)) !== null) {
|
.map((token) => ({
|
||||||
const raw = match[0];
|
norm: normalizeWordForHighlight(token.text),
|
||||||
const leading = raw.match(/^[^A-Za-z0-9]*/)?.[0].length ?? 0;
|
sourceStart: segment.startAnchor.offset + token.start,
|
||||||
const trailing = raw.match(/[^A-Za-z0-9]*$/)?.[0].length ?? 0;
|
sourceEnd: segment.startAnchor.offset + token.end,
|
||||||
const start = match.index + leading;
|
}))
|
||||||
const end = match.index + raw.length - trailing;
|
.filter((token) => Boolean(token.norm));
|
||||||
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 buildMonotonicWordToTokenMap = (
|
||||||
alignmentWords: TTSSentenceAlignment['words'],
|
alignmentWords: TTSSentenceAlignment['words'],
|
||||||
|
|
@ -105,10 +90,12 @@ export const buildMonotonicWordToTokenMap = (
|
||||||
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('::');
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
*/
|
*/
|
||||||
import { CmpStr } from 'cmpstr';
|
import { CmpStr } from 'cmpstr';
|
||||||
import type { TTSSentenceAlignment } from '@/types/tts';
|
import type { TTSSentenceAlignment } from '@/types/tts';
|
||||||
|
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
|
||||||
|
|
||||||
export const HTML_SENTENCE_CLASS = 'openreader-html-highlight-sentence';
|
export const HTML_SENTENCE_CLASS = 'openreader-html-highlight-sentence';
|
||||||
export const HTML_WORD_CLASS = 'openreader-html-highlight-word';
|
export const HTML_WORD_CLASS = 'openreader-html-highlight-word';
|
||||||
|
|
@ -53,21 +54,11 @@ interface SentenceState {
|
||||||
let sentenceState: SentenceState | null = null;
|
let sentenceState: SentenceState | null = null;
|
||||||
|
|
||||||
function normalizeWord(word: string): string {
|
function normalizeWord(word: string): string {
|
||||||
return word
|
return normalizeUnicodeToken(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 +99,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 +126,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 +146,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 +154,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -260,14 +251,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,7 +272,7 @@ 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,
|
wordToToken: null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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 { CmpStr } from 'cmpstr';
|
||||||
import type { TTSSegmentLocator } from '@/types/client';
|
import type { TTSSegmentLocator } from '@/types/client';
|
||||||
|
import { normalizeUnicodeToken, segmentWords } from '@/lib/shared/language';
|
||||||
|
|
||||||
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
|
const cmp = CmpStr.create().setMetric('dice').setFlags('itw');
|
||||||
|
|
||||||
|
|
@ -181,10 +182,7 @@ function getOrCreateHighlightLayer(span: HTMLElement): {
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizeWordForMatch = (text: string): string =>
|
const normalizeWordForMatch = (text: string): string =>
|
||||||
text
|
normalizeUnicodeToken(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 +191,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): {
|
||||||
|
|
@ -451,17 +450,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
119
src/lib/shared/language.ts
Normal file
119
src/lib/shared/language.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
export const DEFAULT_TTS_LANGUAGE = 'en';
|
||||||
|
|
||||||
|
export interface TextToken {
|
||||||
|
text: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KOKORO_LANGUAGE_BY_PREFIX: Readonly<Record<string, string>> = {
|
||||||
|
af: 'en-US',
|
||||||
|
am: 'en-US',
|
||||||
|
bf: 'en-GB',
|
||||||
|
bm: 'en-GB',
|
||||||
|
ef: 'es',
|
||||||
|
em: 'es',
|
||||||
|
ff: 'fr',
|
||||||
|
hf: 'hi',
|
||||||
|
hm: 'hi',
|
||||||
|
if: 'it',
|
||||||
|
im: 'it',
|
||||||
|
jf: 'ja',
|
||||||
|
jm: 'ja',
|
||||||
|
pf: 'pt-BR',
|
||||||
|
pm: 'pt-BR',
|
||||||
|
zf: 'zh-CN',
|
||||||
|
zm: 'zh-CN',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeLanguageTag(
|
||||||
|
language: string | null | undefined,
|
||||||
|
fallback = DEFAULT_TTS_LANGUAGE,
|
||||||
|
): string {
|
||||||
|
const candidate = language?.trim();
|
||||||
|
if (!candidate || candidate.toLowerCase() === 'auto') return fallback;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Intl.getCanonicalLocales(candidate)[0] ?? fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBaseLanguageCode(language: string | null | undefined): string {
|
||||||
|
const normalized = normalizeLanguageTag(language);
|
||||||
|
try {
|
||||||
|
return new Intl.Locale(normalized).language;
|
||||||
|
} catch {
|
||||||
|
return normalized.split('-')[0]?.toLowerCase() || DEFAULT_TTS_LANGUAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferKokoroLanguageFromVoice(voice: string | null | undefined): string | null {
|
||||||
|
if (!voice?.trim()) return null;
|
||||||
|
|
||||||
|
const languages = new Set(
|
||||||
|
voice
|
||||||
|
.split('+')
|
||||||
|
.map((part) => part.trim().replace(/\([^)]*\)/g, ''))
|
||||||
|
.map((name) => KOKORO_LANGUAGE_BY_PREFIX[name.slice(0, 2).toLowerCase()])
|
||||||
|
.filter((language): language is string => Boolean(language)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return languages.size === 1 ? [...languages][0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTtsLanguage(input: {
|
||||||
|
configuredLanguage?: string | null;
|
||||||
|
voice?: string | null;
|
||||||
|
}): string {
|
||||||
|
const configured = input.configuredLanguage?.trim();
|
||||||
|
if (configured && configured.toLowerCase() !== 'auto') {
|
||||||
|
return normalizeLanguageTag(configured);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferKokoroLanguageFromVoice(input.voice) ?? DEFAULT_TTS_LANGUAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeUnicodeToken(text: string): string {
|
||||||
|
return text
|
||||||
|
.normalize('NFKC')
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}\p{M}]+/gu, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function segmentSentences(text: string, language?: string | null): string[] {
|
||||||
|
const normalizedLanguage = normalizeLanguageTag(language);
|
||||||
|
try {
|
||||||
|
return [...new Intl.Segmenter(normalizedLanguage, { granularity: 'sentence' }).segment(text)]
|
||||||
|
.map(({ segment }) => segment.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
return text.split(/(?<=[.!?。!?؟।])\s*/u).map((segment) => segment.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function segmentWords(text: string, language?: string | null): TextToken[] {
|
||||||
|
const normalizedLanguage = normalizeLanguageTag(language);
|
||||||
|
try {
|
||||||
|
return [...new Intl.Segmenter(normalizedLanguage, { granularity: 'word' }).segment(text)]
|
||||||
|
.filter((segment) => segment.isWordLike)
|
||||||
|
.map((segment) => ({
|
||||||
|
text: segment.segment,
|
||||||
|
start: segment.index,
|
||||||
|
end: segment.index + segment.segment.length,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
const tokens: TextToken[] = [];
|
||||||
|
const wordRegex = /\S+/gu;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = wordRegex.exec(text)) !== null) {
|
||||||
|
tokens.push({
|
||||||
|
text: match[0],
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[0].length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 deuxieme 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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -28,6 +28,16 @@ const alignmentWords = (words: string[]): TTSSentenceAlignment['words'] =>
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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('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));
|
||||||
|
|
||||||
|
|
|
||||||
60
tests/unit/language.vitest.spec.ts
Normal file
60
tests/unit/language.vitest.spec.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
inferKokoroLanguageFromVoice,
|
||||||
|
normalizeUnicodeToken,
|
||||||
|
resolveTtsLanguage,
|
||||||
|
segmentSentences,
|
||||||
|
segmentWords,
|
||||||
|
toBaseLanguageCode,
|
||||||
|
} from '../../src/lib/shared/language';
|
||||||
|
|
||||||
|
describe('multilingual language utilities', () => {
|
||||||
|
test('keeps the multilingual document fixture readable as UTF-8', () => {
|
||||||
|
const fixture = readFileSync(resolve(process.cwd(), 'tests/files/multilingual-sample.txt'), 'utf8');
|
||||||
|
expect(fixture).toContain('これは二番目の文です。');
|
||||||
|
expect(fixture).toContain('这是第二句话。');
|
||||||
|
expect(fixture).toContain('هذه هي الجملة الثانية.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('infers Kokoro language from a single-language voice selection', () => {
|
||||||
|
expect(inferKokoroLanguageFromVoice('jf_alpha')).toBe('ja');
|
||||||
|
expect(inferKokoroLanguageFromVoice('zf_xiaobei(0.5)+zm_yunxi(0.5)')).toBe('zh-CN');
|
||||||
|
expect(inferKokoroLanguageFromVoice('ff_siwis+jf_alpha')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers an explicit language and resolves regional tags for Whisper', () => {
|
||||||
|
expect(resolveTtsLanguage({ configuredLanguage: 'pt-BR', voice: 'jf_alpha' })).toBe('pt-BR');
|
||||||
|
expect(resolveTtsLanguage({ configuredLanguage: 'auto', voice: 'jf_alpha' })).toBe('ja');
|
||||||
|
expect(toBaseLanguageCode('zh-CN')).toBe('zh');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes Unicode tokens without dropping non-Latin scripts', () => {
|
||||||
|
expect(normalizeUnicodeToken('École!')).toBe('école');
|
||||||
|
expect(normalizeUnicodeToken('日本語。')).toBe('日本語');
|
||||||
|
expect(normalizeUnicodeToken('العربية؟')).toBe('العربية');
|
||||||
|
expect(normalizeUnicodeToken('हिन्दी।')).toBe('हिन्दी');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('segments CJK sentences and no-space language words', () => {
|
||||||
|
expect(segmentSentences('これは最初の文です。これは二番目の文です。', 'ja')).toEqual([
|
||||||
|
'これは最初の文です。',
|
||||||
|
'これは二番目の文です。',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const chineseWords = segmentWords('这是第一句话。', 'zh').map((token) => token.text);
|
||||||
|
expect(chineseWords.length).toBeGreaterThan(1);
|
||||||
|
expect(chineseWords.join('')).toBe('这是第一句话');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves source offsets for Thai word segmentation', () => {
|
||||||
|
const text = 'นี่คือประโยคแรก';
|
||||||
|
const words = segmentWords(text, 'th');
|
||||||
|
expect(words.length).toBeGreaterThan(1);
|
||||||
|
for (const word of words) {
|
||||||
|
expect(text.slice(word.start, word.end)).toBe(word.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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('最初の文です。次の文です。');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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