feat(tts): detect EPUB language and warn on voice mismatch

This commit is contained in:
Richard R 2026-06-06 09:29:39 -06:00
parent 07e27430c2
commit 696d3e488f
8 changed files with 212 additions and 19 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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