feat(tts): detect EPUB language and warn on voice mismatch
This commit is contained in:
parent
07e27430c2
commit
696d3e488f
8 changed files with 212 additions and 19 deletions
|
|
@ -37,6 +37,7 @@ export default function EPUBPage() {
|
||||||
createFullAudioBook: createEPUBAudioBook,
|
createFullAudioBook: createEPUBAudioBook,
|
||||||
regenerateChapter: regenerateEPUBChapter,
|
regenerateChapter: regenerateEPUBChapter,
|
||||||
bookRef,
|
bookRef,
|
||||||
|
metadataLanguage,
|
||||||
} = epubState;
|
} = epubState;
|
||||||
const { stop, setDocumentLanguage } = useTTS();
|
const { stop, setDocumentLanguage } = useTTS();
|
||||||
const { language, updateLanguage } = useDocumentLanguage(routeDocumentId);
|
const { language, updateLanguage } = useDocumentLanguage(routeDocumentId);
|
||||||
|
|
@ -109,8 +110,8 @@ export default function EPUBPage() {
|
||||||
useUnmountCleanupRef(clearCurrDoc);
|
useUnmountCleanupRef(clearCurrDoc);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDocumentLanguage(language);
|
setDocumentLanguage(language === 'auto' ? metadataLanguage ?? 'auto' : language);
|
||||||
}, [language, setDocumentLanguage]);
|
}, [language, metadataLanguage, setDocumentLanguage]);
|
||||||
|
|
||||||
// Compute available height = viewport - (header height + tts bar height)
|
// Compute available height = viewport - (header height + tts bar height)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -256,6 +257,7 @@ export default function EPUBPage() {
|
||||||
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}
|
language={language}
|
||||||
|
detectedLanguage={metadataLanguage}
|
||||||
onLanguageChange={(nextLanguage) => {
|
onLanguageChange={(nextLanguage) => {
|
||||||
void updateLanguage(nextLanguage);
|
void updateLanguage(nextLanguage);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import type {
|
||||||
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
import type { AudiobookGenerationSettings, TTSSegmentLocator } from '@/types/client';
|
||||||
import { isStableEpubLocator } from '@/types/client';
|
import { isStableEpubLocator } from '@/types/client';
|
||||||
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
import type { CanonicalTtsSegment } from '@/lib/shared/tts-segment-plan';
|
||||||
|
import { normalizeOptionalLanguageTag } from '@/lib/shared/language';
|
||||||
|
|
||||||
export interface EpubDocumentState {
|
export interface EpubDocumentState {
|
||||||
currDocData: ArrayBuffer | undefined;
|
currDocData: ArrayBuffer | undefined;
|
||||||
|
|
@ -51,6 +52,7 @@ export interface EpubDocumentState {
|
||||||
currDocPages: number | undefined;
|
currDocPages: number | undefined;
|
||||||
currDocPage: number | string;
|
currDocPage: number | string;
|
||||||
currDocText: string | undefined;
|
currDocText: string | undefined;
|
||||||
|
metadataLanguage: string | null;
|
||||||
isPlaybackReady: boolean;
|
isPlaybackReady: boolean;
|
||||||
setCurrentDocument: (id: string) => Promise<void>;
|
setCurrentDocument: (id: string) => Promise<void>;
|
||||||
clearCurrDoc: () => void;
|
clearCurrDoc: () => void;
|
||||||
|
|
@ -116,6 +118,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
const [currDocData, setCurrDocData] = useState<ArrayBuffer>();
|
||||||
const [currDocName, setCurrDocName] = useState<string>();
|
const [currDocName, setCurrDocName] = useState<string>();
|
||||||
const [currDocText, setCurrDocText] = useState<string>();
|
const [currDocText, setCurrDocText] = useState<string>();
|
||||||
|
const [metadataLanguage, setMetadataLanguage] = useState<string | null>(null);
|
||||||
const [isPlaybackReady, setIsPlaybackReady] = useState(false);
|
const [isPlaybackReady, setIsPlaybackReady] = useState(false);
|
||||||
// Mirror state into a ref so resolveEpubLocator (registered once with
|
// Mirror state into a ref so resolveEpubLocator (registered once with
|
||||||
// TTSContext via a stable callback) can always read the latest page text
|
// TTSContext via a stable callback) can always read the latest page text
|
||||||
|
|
@ -172,6 +175,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
setCurrDocData(undefined);
|
setCurrDocData(undefined);
|
||||||
setCurrDocName(undefined);
|
setCurrDocName(undefined);
|
||||||
setCurrDocText(undefined);
|
setCurrDocText(undefined);
|
||||||
|
setMetadataLanguage(null);
|
||||||
setIsPlaybackReady(false);
|
setIsPlaybackReady(false);
|
||||||
setCurrDocPages(undefined);
|
setCurrDocPages(undefined);
|
||||||
isEPUBSetOnce.current = false;
|
isEPUBSetOnce.current = false;
|
||||||
|
|
@ -193,6 +197,9 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
const setCurrentDocument = useCallback(async (id: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsPlaybackReady(false);
|
setIsPlaybackReady(false);
|
||||||
|
setMetadataLanguage(null);
|
||||||
|
bookRef.current = null;
|
||||||
|
renditionRef.current = undefined;
|
||||||
const meta = await getDocumentMetadata(id);
|
const meta = await getDocumentMetadata(id);
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
clearCurrDoc();
|
clearCurrDoc();
|
||||||
|
|
@ -380,8 +387,19 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
});
|
});
|
||||||
|
|
||||||
const setRendition = useCallback((rendition: Rendition) => {
|
const setRendition = useCallback((rendition: Rendition) => {
|
||||||
bookRef.current = rendition.book;
|
const book = rendition.book;
|
||||||
|
bookRef.current = book;
|
||||||
renditionRef.current = rendition;
|
renditionRef.current = rendition;
|
||||||
|
void book.loaded.metadata
|
||||||
|
.then((metadata) => {
|
||||||
|
if (bookRef.current !== book) return;
|
||||||
|
setMetadataLanguage(normalizeOptionalLanguageTag(metadata.language));
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (bookRef.current !== book) return;
|
||||||
|
setMetadataLanguage(null);
|
||||||
|
console.warn('Failed to read EPUB language metadata:', error);
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLocationChanged = useEPUBLocationController({
|
const handleLocationChanged = useEPUBLocationController({
|
||||||
|
|
@ -406,6 +424,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
currDocPages,
|
currDocPages,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocText,
|
currDocText,
|
||||||
|
metadataLanguage,
|
||||||
isPlaybackReady,
|
isPlaybackReady,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
|
|
@ -432,6 +451,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState {
|
||||||
currDocPages,
|
currDocPages,
|
||||||
currDocPage,
|
currDocPage,
|
||||||
currDocText,
|
currDocText,
|
||||||
|
metadataLanguage,
|
||||||
isPlaybackReady,
|
isPlaybackReady,
|
||||||
clearCurrDoc,
|
clearCurrDoc,
|
||||||
extractPageText,
|
extractPageText,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +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 { getTtsLanguageCompatibilityWarnings, resolveTtsLanguage } from '@/lib/shared/language';
|
||||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||||
import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui';
|
import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui';
|
||||||
import {
|
import {
|
||||||
|
|
@ -132,6 +132,11 @@ export function AudiobookExportModal({
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions, documentLanguage]);
|
}, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions, documentLanguage]);
|
||||||
|
const languageWarnings = useMemo(() => getTtsLanguageCompatibilityWarnings({
|
||||||
|
model: effectiveSettings?.ttsModel,
|
||||||
|
voice: effectiveSettings?.voice,
|
||||||
|
documentLanguage: effectiveSettings?.language,
|
||||||
|
}), [effectiveSettings]);
|
||||||
|
|
||||||
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
const fetchExistingChapters = useCallback(async (soft: boolean = false) => {
|
||||||
if (soft) {
|
if (soft) {
|
||||||
|
|
@ -610,6 +615,11 @@ export function AudiobookExportModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{languageWarnings.map((warning) => (
|
||||||
|
<p key={warning} className="text-xs text-warning">
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
{/* Speed controls */}
|
{/* Speed controls */}
|
||||||
<Card className="p-3 space-y-3">
|
<Card className="p-3 space-y-3">
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
import { useConfig, ViewType } from '@/contexts/ConfigContext';
|
||||||
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell';
|
||||||
import {
|
import {
|
||||||
SEGMENT_PRELOAD_DEPTH_MIN,
|
SEGMENT_PRELOAD_DEPTH_MIN,
|
||||||
|
|
@ -19,6 +20,7 @@ import { IconButton, RangeInput, Section, ToggleRow, CheckItem, SegmentedControl
|
||||||
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
|
import { RefreshIcon, SparkleIcon } from '@/components/icons/Icons';
|
||||||
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
import type { ParsedPdfBlockKind, PdfParseStatus } from '@/types/parsed-pdf';
|
||||||
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
|
import { isForceReparseDisabled } from '@/lib/client/pdf/force-reparse';
|
||||||
|
import { getLanguageDisplayName, getTtsLanguageCompatibilityWarnings } from '@/lib/shared/language';
|
||||||
|
|
||||||
const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [
|
const PDF_SKIP_KIND_OPTIONS: Array<{ kind: ParsedPdfBlockKind; label: string }> = [
|
||||||
{ kind: 'header', label: 'Header' },
|
{ kind: 'header', label: 'Header' },
|
||||||
|
|
@ -106,12 +108,13 @@ function RangeSetting({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, onLanguageChange, pdf }: {
|
export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, detectedLanguage, onLanguageChange, pdf }: {
|
||||||
isOpen: boolean,
|
isOpen: boolean,
|
||||||
setIsOpen: (isOpen: boolean) => void,
|
setIsOpen: (isOpen: boolean) => void,
|
||||||
epub?: boolean,
|
epub?: boolean,
|
||||||
html?: boolean,
|
html?: boolean,
|
||||||
language?: string,
|
language?: string,
|
||||||
|
detectedLanguage?: string | null,
|
||||||
onLanguageChange?: (language: string) => void,
|
onLanguageChange?: (language: string) => void,
|
||||||
pdf?: {
|
pdf?: {
|
||||||
parseStatus: PdfParseStatus | null;
|
parseStatus: PdfParseStatus | null;
|
||||||
|
|
@ -137,7 +140,14 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, onLa
|
||||||
epubWordHighlightEnabled,
|
epubWordHighlightEnabled,
|
||||||
htmlHighlightEnabled,
|
htmlHighlightEnabled,
|
||||||
htmlWordHighlightEnabled,
|
htmlWordHighlightEnabled,
|
||||||
|
ttsModel,
|
||||||
} = useConfig();
|
} = useConfig();
|
||||||
|
const { voice, resolvedLanguage } = useTTS();
|
||||||
|
const languageWarnings = getTtsLanguageCompatibilityWarnings({
|
||||||
|
model: ttsModel,
|
||||||
|
voice,
|
||||||
|
documentLanguage: resolvedLanguage,
|
||||||
|
});
|
||||||
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
const selectedView = viewTypeTextMapping.find(v => v.id === viewType) || viewTypeTextMapping[0];
|
||||||
const isPdfMode = !epub && !html && !!pdf;
|
const isPdfMode = !epub && !html && !!pdf;
|
||||||
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
|
const [localPreloadDepth, setLocalPreloadDepth] = useState(segmentPreloadDepthPages);
|
||||||
|
|
@ -189,6 +199,16 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html, language, onLa
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{language === 'auto' && detectedLanguage ? (
|
||||||
|
<p className="text-xs text-soft">
|
||||||
|
Detected from document metadata: {getLanguageDisplayName(detectedLanguage)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{languageWarnings.map((warning) => (
|
||||||
|
<p key={warning} className="text-xs text-warning">
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
</Section>
|
</Section>
|
||||||
) : null}
|
) : null}
|
||||||
{isPdfMode && pdf && (
|
{isPdfMode && pdf && (
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,40 @@ import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
import { VoicesControlBase } from '@/components/player/VoicesControlBase';
|
||||||
|
import { InfoIcon } from '@/components/icons/Icons';
|
||||||
|
import { getTtsLanguageCompatibilityWarnings } from '@/lib/shared/language';
|
||||||
|
|
||||||
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
export const VoicesControl = ({ availableVoices, setVoiceAndRestart }: {
|
||||||
availableVoices: string[];
|
availableVoices: string[];
|
||||||
setVoiceAndRestart: (voice: string) => void;
|
setVoiceAndRestart: (voice: string) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const { ttsModel, providerType } = useConfig();
|
const { ttsModel, providerType } = useConfig();
|
||||||
const { voice } = useTTS();
|
const { voice, resolvedLanguage } = useTTS();
|
||||||
const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]);
|
const onChangeVoice = useCallback((nextVoice: string) => setVoiceAndRestart(nextVoice), [setVoiceAndRestart]);
|
||||||
|
const languageWarnings = getTtsLanguageCompatibilityWarnings({
|
||||||
|
model: ttsModel,
|
||||||
|
voice,
|
||||||
|
documentLanguage: resolvedLanguage,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VoicesControlBase
|
<div className="flex items-center gap-1">
|
||||||
availableVoices={availableVoices}
|
<VoicesControlBase
|
||||||
voice={voice || ''}
|
availableVoices={availableVoices}
|
||||||
onChangeVoice={onChangeVoice}
|
voice={voice || ''}
|
||||||
providerType={providerType}
|
onChangeVoice={onChangeVoice}
|
||||||
ttsModel={ttsModel}
|
providerType={providerType}
|
||||||
/>
|
ttsModel={ttsModel}
|
||||||
|
/>
|
||||||
|
{languageWarnings.length > 0 ? (
|
||||||
|
<span
|
||||||
|
aria-label="Voice language warning"
|
||||||
|
title={languageWarnings.join(' ')}
|
||||||
|
className="text-warning"
|
||||||
|
>
|
||||||
|
<InfoIcon className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ function settingsAreEqual(a: TTSSegmentSettings | null, b: TTSSegmentSettings |
|
||||||
&& a.voice === b.voice
|
&& a.voice === b.voice
|
||||||
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
|
&& Number(a.nativeSpeed) === Number(b.nativeSpeed)
|
||||||
&& (a.ttsInstructions || '') === (b.ttsInstructions || '')
|
&& (a.ttsInstructions || '') === (b.ttsInstructions || '')
|
||||||
|
&& (a.language || 'en') === (b.language || 'en')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,6 +187,7 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
||||||
playFromSegment,
|
playFromSegment,
|
||||||
activeReaderType,
|
activeReaderType,
|
||||||
clearSegmentCaches,
|
clearSegmentCaches,
|
||||||
|
resolvedLanguage,
|
||||||
} = useTTS();
|
} = useTTS();
|
||||||
const {
|
const {
|
||||||
providerRef,
|
providerRef,
|
||||||
|
|
@ -348,7 +350,8 @@ export function SegmentsSidebar({ isOpen, setIsOpen, documentId, epubBookRef }:
|
||||||
voice,
|
voice,
|
||||||
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
|
nativeSpeed: Number.isFinite(Number(voiceSpeed)) ? Number(voiceSpeed) : 1,
|
||||||
ttsInstructions: ttsInstructions || '',
|
ttsInstructions: ttsInstructions || '',
|
||||||
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions]);
|
language: resolvedLanguage,
|
||||||
|
}), [providerRef, providerType, ttsModel, voice, voiceSpeed, ttsInstructions, resolvedLanguage]);
|
||||||
|
|
||||||
const handleClearCache = useCallback(async () => {
|
const handleClearCache = useCallback(async () => {
|
||||||
if (!documentId || isClearingSegments) return;
|
if (!documentId || isClearingSegments) return;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { isKokoroModel } from '@/lib/shared/kokoro';
|
||||||
|
|
||||||
export const DEFAULT_TTS_LANGUAGE = 'en';
|
export const DEFAULT_TTS_LANGUAGE = 'en';
|
||||||
|
|
||||||
export interface TextToken {
|
export interface TextToken {
|
||||||
|
|
@ -26,6 +28,17 @@ const KOKORO_LANGUAGE_BY_PREFIX: Readonly<Record<string, string>> = {
|
||||||
zm: 'zh-CN',
|
zm: 'zh-CN',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const KOKORO_SUPPORTED_LANGUAGES = [
|
||||||
|
'en',
|
||||||
|
'es',
|
||||||
|
'fr',
|
||||||
|
'hi',
|
||||||
|
'it',
|
||||||
|
'ja',
|
||||||
|
'pt-BR',
|
||||||
|
'zh-CN',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function normalizeLanguageTag(
|
export function normalizeLanguageTag(
|
||||||
language: string | null | undefined,
|
language: string | null | undefined,
|
||||||
fallback = DEFAULT_TTS_LANGUAGE,
|
fallback = DEFAULT_TTS_LANGUAGE,
|
||||||
|
|
@ -40,6 +53,17 @@ export function normalizeLanguageTag(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeOptionalLanguageTag(language: unknown): string | null {
|
||||||
|
if (typeof language !== 'string') return null;
|
||||||
|
const candidate = language.trim().split(/[,\s]+/u)[0];
|
||||||
|
if (!candidate) return null;
|
||||||
|
try {
|
||||||
|
return Intl.getCanonicalLocales(candidate)[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function toBaseLanguageCode(language: string | null | undefined): string {
|
export function toBaseLanguageCode(language: string | null | undefined): string {
|
||||||
const normalized = normalizeLanguageTag(language);
|
const normalized = normalizeLanguageTag(language);
|
||||||
try {
|
try {
|
||||||
|
|
@ -50,17 +74,20 @@ export function toBaseLanguageCode(language: string | null | undefined): string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function inferKokoroLanguageFromVoice(voice: string | null | undefined): string | null {
|
export function inferKokoroLanguageFromVoice(voice: string | null | undefined): string | null {
|
||||||
if (!voice?.trim()) return null;
|
const languages = new Set(getKokoroVoiceLanguages(voice));
|
||||||
|
|
||||||
const languages = new Set(
|
return languages.size === 1 ? [...languages][0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getKokoroVoiceLanguages(voice: string | null | undefined): string[] {
|
||||||
|
if (!voice?.trim()) return [];
|
||||||
|
return Array.from(new Set(
|
||||||
voice
|
voice
|
||||||
.split('+')
|
.split('+')
|
||||||
.map((part) => part.trim().replace(/\([^)]*\)/g, ''))
|
.map((part) => part.trim().replace(/\([^)]*\)/g, ''))
|
||||||
.map((name) => KOKORO_LANGUAGE_BY_PREFIX[name.slice(0, 2).toLowerCase()])
|
.map((name) => KOKORO_LANGUAGE_BY_PREFIX[name.slice(0, 2).toLowerCase()])
|
||||||
.filter((language): language is string => Boolean(language)),
|
.filter((language): language is string => Boolean(language)),
|
||||||
);
|
));
|
||||||
|
|
||||||
return languages.size === 1 ? [...languages][0] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTtsLanguage(input: {
|
export function resolveTtsLanguage(input: {
|
||||||
|
|
@ -75,6 +102,51 @@ export function resolveTtsLanguage(input: {
|
||||||
return inferKokoroLanguageFromVoice(input.voice) ?? DEFAULT_TTS_LANGUAGE;
|
return inferKokoroLanguageFromVoice(input.voice) ?? DEFAULT_TTS_LANGUAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLanguageDisplayName(language: string): string {
|
||||||
|
try {
|
||||||
|
return new Intl.DisplayNames(['en'], { type: 'language' }).of(language) || language;
|
||||||
|
} catch {
|
||||||
|
return language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTtsLanguageCompatibilityWarnings(input: {
|
||||||
|
model?: string | null;
|
||||||
|
voice?: string | null;
|
||||||
|
documentLanguage?: string | null;
|
||||||
|
}): string[] {
|
||||||
|
if (!isKokoroModel(input.model || '')) return [];
|
||||||
|
|
||||||
|
const documentLanguage = normalizeLanguageTag(input.documentLanguage);
|
||||||
|
const documentBaseLanguage = toBaseLanguageCode(documentLanguage);
|
||||||
|
const supportedBaseLanguages = new Set(KOKORO_SUPPORTED_LANGUAGES.map((language) => toBaseLanguageCode(language)));
|
||||||
|
const voiceLanguages = getKokoroVoiceLanguages(input.voice);
|
||||||
|
const voiceBaseLanguages = new Set(voiceLanguages.map((language) => toBaseLanguageCode(language)));
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (!supportedBaseLanguages.has(documentBaseLanguage)) {
|
||||||
|
warnings.push(
|
||||||
|
`Kokoro's built-in voice catalog does not include ${getLanguageDisplayName(documentLanguage)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (voiceBaseLanguages.size > 1) {
|
||||||
|
warnings.push(
|
||||||
|
`Selected Kokoro voices use multiple languages (${voiceLanguages.map(getLanguageDisplayName).join(', ')}).`,
|
||||||
|
);
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceLanguage = voiceLanguages[0];
|
||||||
|
if (voiceLanguage && toBaseLanguageCode(voiceLanguage) !== documentBaseLanguage) {
|
||||||
|
warnings.push(
|
||||||
|
`Selected Kokoro voice is ${getLanguageDisplayName(voiceLanguage)}, but the document is ${getLanguageDisplayName(documentLanguage)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeUnicodeToken(text: string): string {
|
export function normalizeUnicodeToken(text: string): string {
|
||||||
return text
|
return text
|
||||||
.normalize('NFKC')
|
.normalize('NFKC')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import { readFileSync } from 'fs';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getTtsLanguageCompatibilityWarnings,
|
||||||
inferKokoroLanguageFromVoice,
|
inferKokoroLanguageFromVoice,
|
||||||
|
normalizeOptionalLanguageTag,
|
||||||
normalizeUnicodeToken,
|
normalizeUnicodeToken,
|
||||||
resolveTtsLanguage,
|
resolveTtsLanguage,
|
||||||
segmentSentences,
|
segmentSentences,
|
||||||
|
|
@ -25,6 +27,52 @@ describe('multilingual language utilities', () => {
|
||||||
expect(inferKokoroLanguageFromVoice('ff_siwis+jf_alpha')).toBeNull();
|
expect(inferKokoroLanguageFromVoice('ff_siwis+jf_alpha')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizes valid EPUB metadata language tags and rejects invalid metadata', () => {
|
||||||
|
expect(normalizeOptionalLanguageTag(' ja-jp ')).toBe('ja-JP');
|
||||||
|
expect(normalizeOptionalLanguageTag('fr, en')).toBe('fr');
|
||||||
|
expect(normalizeOptionalLanguageTag('not_a_language')).toBeNull();
|
||||||
|
expect(normalizeOptionalLanguageTag(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warns only for provable Kokoro language compatibility problems', () => {
|
||||||
|
expect(getTtsLanguageCompatibilityWarnings({
|
||||||
|
model: 'kokoro',
|
||||||
|
voice: 'jf_alpha',
|
||||||
|
documentLanguage: 'ja',
|
||||||
|
})).toEqual([]);
|
||||||
|
|
||||||
|
expect(getTtsLanguageCompatibilityWarnings({
|
||||||
|
model: 'kokoro',
|
||||||
|
voice: 'af_sarah',
|
||||||
|
documentLanguage: 'ja',
|
||||||
|
})).toEqual([
|
||||||
|
'Selected Kokoro voice is American English, but the document is Japanese.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getTtsLanguageCompatibilityWarnings({
|
||||||
|
model: 'kokoro',
|
||||||
|
voice: 'ff_siwis+jf_alpha',
|
||||||
|
documentLanguage: 'fr',
|
||||||
|
})).toEqual([
|
||||||
|
'Selected Kokoro voices use multiple languages (French, Japanese).',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getTtsLanguageCompatibilityWarnings({
|
||||||
|
model: 'kokoro',
|
||||||
|
voice: 'af_sarah',
|
||||||
|
documentLanguage: 'ar',
|
||||||
|
})).toEqual([
|
||||||
|
"Kokoro's built-in voice catalog does not include Arabic.",
|
||||||
|
'Selected Kokoro voice is American English, but the document is Arabic.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getTtsLanguageCompatibilityWarnings({
|
||||||
|
model: 'custom-unknown-model',
|
||||||
|
voice: 'af_sarah',
|
||||||
|
documentLanguage: 'ja',
|
||||||
|
})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('prefers an explicit language and resolves regional tags for Whisper', () => {
|
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: 'pt-BR', voice: 'jf_alpha' })).toBe('pt-BR');
|
||||||
expect(resolveTtsLanguage({ configuredLanguage: 'auto', voice: 'jf_alpha' })).toBe('ja');
|
expect(resolveTtsLanguage({ configuredLanguage: 'auto', voice: 'jf_alpha' })).toBe('ja');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue