feat(tts): add an option to select target language for TTS on translated books (#2310)

This commit is contained in:
Huang Xin
2025-10-24 01:34:43 +08:00
committed by GitHub
parent 4acbc33762
commit 86efcde927
33 changed files with 248 additions and 32 deletions
@@ -632,5 +632,9 @@
"Tap Both Sides": "اضغط على كلا الجانبين",
"Click Both Sides": "انقر على كلا الجانبين",
"Swap Tap Sides": "تبديل جانبي اللمس",
"Swap Click Sides": "تبديل جانبي النقر"
"Swap Click Sides": "تبديل جانبي النقر",
"Source and Translated": "المصدر والمترجم",
"Translated Only": "المترجم فقط",
"Source Only": "المصدر فقط",
"TTS Text": "نص تحويل النص إلى كلام"
}
@@ -616,5 +616,9 @@
"Tap Both Sides": "দুই পাশে ট্যাপ করুন",
"Click Both Sides": "দুই পাশে ক্লিক করুন",
"Swap Tap Sides": "ট্যাপের পাশে পরিবর্তন করুন",
"Swap Click Sides": "ক্লিকের পাশে পরিবর্তন করুন"
"Swap Click Sides": "ক্লিকের পাশে পরিবর্তন করুন",
"Source and Translated": "উৎস এবং অনূদিত",
"Translated Only": "শুধুমাত্র অনূদিত",
"Source Only": "শুধুমাত্র উৎস",
"TTS Text": "TTS টেক্সট"
}
@@ -612,5 +612,9 @@
"Tap Both Sides": "གཡས་གཉིས་ལ་ཐོག་འགྲོ།",
"Click Both Sides": "གཡས་གཉིས་ལ་ཁྱེད་ལུས་འགྲོ།",
"Swap Tap Sides": "ཐོག་གཡས་གཉིས་བརྗེ་བ།",
"Swap Click Sides": "ཁྱེད་ལུས་གཡས་གཉིས་བརྗེ་བ།"
"Swap Click Sides": "ཁྱེད་ལུས་གཡས་གཉིས་བརྗེ་བ།",
"Source and Translated": "ཡིག་སྒྱུར་སྤོ་བ།",
"Translated Only": "མདོག་ཚད་ཀྱི་བསྡུར་བ།",
"Source Only": "ཡིག་སྒྱུར་སྤོ་བ།",
"TTS Text": "TTS ཡིག"
}
@@ -616,5 +616,9 @@
"Tap Both Sides": "Tippen Sie auf beide Seiten",
"Click Both Sides": "Klicken Sie auf beide Seiten",
"Swap Tap Sides": "Seiten für das Tippen tauschen",
"Swap Click Sides": "Seiten für das Klicken tauschen"
"Swap Click Sides": "Seiten für das Klicken tauschen",
"Source and Translated": "Quell- und Übersetzungstext",
"Translated Only": "Nur Übersetzungstext",
"Source Only": "Nur Quelltext",
"TTS Text": "TTS-Text"
}
@@ -616,5 +616,9 @@
"Tap Both Sides": "Ταπ και στις δύο πλευρές",
"Click Both Sides": "Κλικ και στις δύο πλευρές",
"Swap Tap Sides": "Ανταλλαγή πλευρών ταπ",
"Swap Click Sides": "Ανταλλαγή πλευρών κλικ"
"Swap Click Sides": "Ανταλλαγή πλευρών κλικ",
"Source and Translated": "Πηγή και Μεταφρασμένο",
"Translated Only": "Μόνο Μεταφρασμένο",
"Source Only": "Μόνο πηγή",
"TTS Text": "Κείμενο TTS"
}
@@ -620,5 +620,9 @@
"Tap Both Sides": "Tocar Ambos Lados",
"Click Both Sides": "Hacer Clic en Ambos Lados",
"Swap Tap Sides": "Intercambiar Lados de Toque",
"Swap Click Sides": "Intercambiar Lados de Clic"
"Swap Click Sides": "Intercambiar Lados de Clic",
"Source and Translated": "Fuente y Traducido",
"Translated Only": "Solo Traducido",
"Source Only": "Solo Fuente",
"TTS Text": "TTS Texto"
}
@@ -620,5 +620,9 @@
"Tap Both Sides": "Tapoter des Deux Côtés",
"Click Both Sides": "Cliquer des Deux Côtés",
"Swap Tap Sides": "Échanger les Côtés de Tap",
"Swap Click Sides": "Échanger les Côtés de Clic"
"Swap Click Sides": "Échanger les Côtés de Clic",
"Source and Translated": "Source et Traduit",
"Translated Only": "Traduit Seulement",
"Source Only": "Source Seulement",
"TTS Text": "TTS Texte"
}
@@ -616,5 +616,9 @@
"Tap Both Sides": "दोनों पक्षों पर टैप करें",
"Click Both Sides": "दोनों पक्षों पर क्लिक करें",
"Swap Tap Sides": "स्वैप टैप साइड्स",
"Swap Click Sides": "स्वैप क्लिक साइड्स"
"Swap Click Sides": "स्वैप क्लिक साइड्स",
"Source and Translated": "स्रोत और अनुवादित",
"Translated Only": "केवल अनुवादित",
"Source Only": "केवल स्रोत",
"TTS Text": "TTS टेक्स्ट"
}
@@ -612,5 +612,9 @@
"Tap Both Sides": "Ketuk Kedua Sisi",
"Click Both Sides": "Klik Kedua Sisi",
"Swap Tap Sides": "Tukar Sisi Ketuk",
"Swap Click Sides": "Tukar Sisi Klik"
"Swap Click Sides": "Tukar Sisi Klik",
"Source and Translated": "Sumber dan Terjemahan",
"Translated Only": "Hanya Terjemahan",
"Source Only": "Hanya Sumber",
"TTS Text": "Teks TTS"
}
@@ -620,5 +620,9 @@
"Tap Both Sides": "Tocca Entrambi i Lati",
"Click Both Sides": "Clicca Entrambi i Lati",
"Swap Tap Sides": "Scambia Sides Tap",
"Swap Click Sides": "Scambia Sides Click"
"Swap Click Sides": "Scambia Sides Click",
"Source and Translated": "Fonte e Tradotto",
"Translated Only": "Solo Tradotto",
"Source Only": "Solo Fonte",
"TTS Text": "Testo TTS"
}
@@ -612,5 +612,9 @@
"Tap Both Sides": "両側をタップ",
"Click Both Sides": "両側をクリック",
"Swap Tap Sides": "タップサイドを入れ替える",
"Swap Click Sides": "クリックサイドを入れ替える"
"Swap Click Sides": "クリックサイドを入れ替える",
"Source and Translated": "原文と翻訳",
"Translated Only": "翻訳のみ",
"Source Only": "原文のみ",
"TTS Text": "TTSテキスト"
}
@@ -612,5 +612,9 @@
"Tap Both Sides": "양쪽을 탭",
"Click Both Sides": "양쪽을 클릭",
"Swap Tap Sides": "탭 사이드 전환",
"Swap Click Sides": "클릭 사이드 전환"
"Swap Click Sides": "클릭 사이드 전환",
"Source and Translated": "원본 및 번역",
"Translated Only": "번역만",
"Source Only": "원본만",
"TTS Text": "TTS 텍스트"
}
@@ -616,5 +616,9 @@
"Tap Both Sides": "Tik op Beide Zijden",
"Click Both Sides": "Klik op Beide Zijden",
"Swap Tap Sides": "Wissel Tap Zijden",
"Swap Click Sides": "Wissel Klik Zijden"
"Swap Click Sides": "Wissel Klik Zijden",
"Source and Translated": "Bron en Vertaling",
"Translated Only": "Alleen Vertaling",
"Source Only": "Alleen Bron",
"TTS Text": "TTS Tekst"
}
@@ -624,5 +624,9 @@
"Tap Both Sides": "Dotknij obu stron",
"Click Both Sides": "Kliknij obu stron",
"Swap Tap Sides": "Wymień Tap Zewnętrzne",
"Swap Click Sides": "Wymień Click Zewnętrzne"
"Swap Click Sides": "Wymień Click Zewnętrzne",
"Source and Translated": "Źródło i Tłumaczenie",
"Translated Only": "Tylko Tłumaczenie",
"Source Only": "Tylko Źródło",
"TTS Text": "Tekst TTS"
}
@@ -620,5 +620,9 @@
"Tap Both Sides": "Toque em Ambos os Lados",
"Click Both Sides": "Clique em Ambos os Lados",
"Swap Tap Sides": "Trocar Lados do Toque",
"Swap Click Sides": "Trocar Lados do Clique"
"Swap Click Sides": "Trocar Lados do Clique",
"Source and Translated": "Fonte e Traduzido",
"Translated Only": "Apenas Traduzido",
"Source Only": "Apenas Fonte",
"TTS Text": "TTS Texto"
}
@@ -624,5 +624,9 @@
"Tap Both Sides": "Тапните обе стороны",
"Click Both Sides": "Нажмите обе стороны",
"Swap Tap Sides": "Переключить стороны касания",
"Swap Click Sides": "Переключить стороны клика"
"Swap Click Sides": "Переключить стороны клика",
"Source and Translated": "Источник и перевод",
"Translated Only": "Только перевод",
"Source Only": "Только источник",
"TTS Text": "TTS Текст"
}
@@ -616,5 +616,9 @@
"Tap Both Sides": "පැත්ත දෙකම ටැප් කරන්න",
"Click Both Sides": "පැත්ත දෙකම ක්ලික් කරන්න",
"Swap Tap Sides": "පැත්ත දෙකම ටැප් කරන්න",
"Swap Click Sides": "පැත්ත දෙකම ක්ලික් කරන්න"
"Swap Click Sides": "පැත්ත දෙකම ක්ලික් කරන්න",
"Source and Translated": "මූලාශ්‍ර සහ පරිවර්තනය",
"Translated Only": "පරිවර්තනය පමණක්",
"Source Only": "මූලාශ්‍රය පමණක්",
"TTS Text": "TTS පෙළ"
}
@@ -616,5 +616,9 @@
"Tap Both Sides": "Tryck på båda sidor",
"Click Both Sides": "Klicka på båda sidor",
"Swap Tap Sides": "Byt plats på tap-sidor",
"Swap Click Sides": "Byt plats på klick-sidor"
"Swap Click Sides": "Byt plats på klick-sidor",
"Source and Translated": "Källa och översatt",
"Translated Only": "Översatt endast",
"Source Only": "Endast källa",
"TTS Text": "TTS-text"
}
@@ -616,5 +616,9 @@
"Tap Both Sides": "இரு பக்கங்களையும் தொடுங்கள்",
"Click Both Sides": "இரு பக்கங்களையும் கிளிக் செய்யவும்",
"Swap Tap Sides": "இரு பக்கங்களின் இடத்தை மாற்றவும்",
"Swap Click Sides": "இரு பக்கங்களின் இடத்தை மாற்றவும்"
"Swap Click Sides": "இரு பக்கங்களின் இடத்தை மாற்றவும்",
"Source and Translated": "மூல மற்றும் மொழிபெயர்க்கப்பட்டது",
"Translated Only": "மொழிபெயர்க்கப்பட்டது மட்டும்",
"Source Only": "மூல மட்டும்",
"TTS Text": "TTS உரை"
}
@@ -612,5 +612,9 @@
"Tap Both Sides": "แตะทั้งสองด้าน",
"Click Both Sides": "คลิกทั้งสองด้าน",
"Swap Tap Sides": "เปลี่ยนด้านการแตะ",
"Swap Click Sides": "เปลี่ยนด้านการคลิก"
"Swap Click Sides": "เปลี่ยนด้านการคลิก",
"Source and Translated": "ต้นฉบับและแปลแล้ว",
"Translated Only": "แปลแล้วเท่านั้น",
"Source Only": "ต้นฉบับเท่านั้น",
"TTS Text": "ข้อความ TTS"
}
@@ -616,5 +616,9 @@
"Tap Both Sides": "Her İki Tarafa Dokunun",
"Click Both Sides": "Her İki Tarafa Tıklayın",
"Swap Tap Sides": "Dokunma Taraflarını Değiştir",
"Swap Click Sides": "Tıklama Taraflarını Değiştir"
"Swap Click Sides": "Tıklama Taraflarını Değiştir",
"Source and Translated": "Kaynak ve Çeviri",
"Translated Only": "Yalnızca Çeviri",
"Source Only": "Yalnızca Kaynak",
"TTS Text": "TTS Metni"
}
@@ -624,5 +624,9 @@
"Tap Both Sides": "Натисніть обидві сторони",
"Click Both Sides": "Клікніть обидві сторони",
"Swap Tap Sides": "Змінити сторони дотику",
"Swap Click Sides": "Змінити сторони кліку"
"Swap Click Sides": "Змінити сторони кліку",
"Source and Translated": "Джерело та переклад",
"Translated Only": "Тільки переклад",
"Source Only": "Тільки джерело",
"TTS Text": "Текст TTS"
}
@@ -612,5 +612,9 @@
"Tap Both Sides": "Chạm vào cả hai bên",
"Click Both Sides": "Nhấp vào cả hai bên",
"Swap Tap Sides": "Đổi bên chạm",
"Swap Click Sides": "Đổi bên nhấp"
"Swap Click Sides": "Đổi bên nhấp",
"Source and Translated": "Nguồn và Đã dịch",
"Translated Only": "Chỉ Đã dịch",
"Source Only": "Chỉ Nguồn",
"TTS Text": "Văn bản TTS"
}
@@ -612,5 +612,9 @@
"Tap Both Sides": "点击两侧翻页",
"Click Both Sides": "点击两侧翻页",
"Swap Tap Sides": "交换点击区域",
"Swap Click Sides": "交换点击区域"
"Swap Click Sides": "交换点击区域",
"Source and Translated": "原文和译文",
"Translated Only": "仅译文",
"Source Only": "仅原文",
"TTS Text": "TTS 文本"
}
@@ -612,5 +612,9 @@
"Tap Both Sides": "點擊兩側翻頁",
"Click Both Sides": "點擊兩側翻頁",
"Swap Tap Sides": "交換點擊區域",
"Swap Click Sides": "交換點擊區域"
"Swap Click Sides": "交換點擊區域",
"Source and Translated": "原文和翻譯",
"Translated Only": "僅翻譯",
"Source Only": "僅原文",
"TTS Text": "TTS 文字"
}
@@ -46,7 +46,7 @@ const TranslationToggler = ({ bookKey }: { bookKey: string }) => {
<RiTranslateAi className={translationEnabled ? 'text-blue-500' : 'text-base-content'} />
}
aria-label={_('Toggle Translation')}
disabled={!translationAvailable}
disabled={!translationAvailable && !translationEnabled}
onClick={() => setTranslationEnabled(!translationEnabled)}
label={
translationAvailable
@@ -18,6 +18,7 @@ import { Insets } from '@/types/misc';
import { Overlay } from '@/components/Overlay';
import { fetchImageAsBase64 } from '@/utils/image';
import { invokeUseBackgroundAudio } from '@/utils/bridge';
import { getLocale } from '@/utils/misc';
import Popup from '@/components/Popup';
import TTSPanel from './TTSPanel';
import TTSIcon from './TTSIcon';
@@ -246,6 +247,27 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsController, bookKey]);
const getTTSTargetLang = useCallback((): string | null => {
const ttsReadAloudText = viewSettings?.ttsReadAloudText;
if (viewSettings?.translationEnabled && ttsReadAloudText === 'translated') {
return viewSettings?.translateTargetLang || getLocale();
} else if (viewSettings?.translationEnabled && ttsReadAloudText === 'source') {
const bookData = getBookData(bookKey);
return bookData?.book?.primaryLanguage || '';
}
return null;
}, [
bookKey,
getBookData,
viewSettings?.translationEnabled,
viewSettings?.ttsReadAloudText,
viewSettings?.translateTargetLang,
]);
useEffect(() => {
ttsControllerRef.current?.setTargetLang(getTTSTargetLang() || '');
}, [getTTSTargetLang]);
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey: ttsBookKey, range } = event.detail;
if (bookKey !== ttsBookKey) return;
@@ -311,6 +333,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey, gridInsets }) => {
ttsController.setLang(lang);
ttsController.setRate(viewSettings.ttsRate);
ttsController.speak(ssml);
ttsController.setTargetLang(getTTSTargetLang() || '');
ttsControllerRef.current = ttsController;
setTtsController(ttsController);
}
@@ -26,6 +26,7 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
const [translationProvider, setTranslationProvider] = useState(viewSettings.translationProvider);
const [translateTargetLang, setTranslateTargetLang] = useState(viewSettings.translateTargetLang);
const [showTranslateSource, setShowTranslateSource] = useState(viewSettings.showTranslateSource);
const [ttsReadAloudText, setTtsReadAloudText] = useState(viewSettings.ttsReadAloudText);
const resetToDefaults = useResetViewSettings();
@@ -35,6 +36,8 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
translationEnabled: setTranslationEnabled,
translationProvider: setTranslationProvider,
translateTargetLang: setTranslateTargetLang,
showTranslateSource: setShowTranslateSource,
ttsReadAloudText: setTtsReadAloudText,
});
};
@@ -114,6 +117,20 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
setViewSettings(bookKey, { ...viewSettings });
};
const handleSelectTTSText = (event: React.ChangeEvent<HTMLSelectElement>) => {
const option = event.target.value;
setTtsReadAloudText(option);
saveViewSettings(envConfig, bookKey, 'ttsReadAloudText', option, false, false);
};
const getTTSTextOptions = () => {
return [
{ value: 'both', label: _('Source and Translated') },
{ value: 'translated', label: _('Translated Only') },
{ value: 'source', label: _('Source Only') },
];
};
useEffect(() => {
if (uiLanguage === viewSettings.uiLanguage) return;
saveViewSettings(envConfig, bookKey, 'uiLanguage', uiLanguage, false, false);
@@ -139,6 +156,12 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showTranslateSource]);
useEffect(() => {
if (ttsReadAloudText === viewSettings.ttsReadAloudText) return;
saveViewSettings(envConfig, bookKey, 'ttsReadAloudText', ttsReadAloudText, false, false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsReadAloudText]);
return (
<div className={clsx('my-4 w-full space-y-6')}>
<div className='w-full'>
@@ -177,18 +200,25 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
type='checkbox'
className='toggle'
checked={showTranslateSource}
disabled={!translationEnabled}
onChange={() => setShowTranslateSource(!showTranslateSource)}
/>
</div>
<div className='config-item'>
<span className=''>{_('TTS Text')}</span>
<Select
value={ttsReadAloudText}
onChange={handleSelectTTSText}
options={getTTSTextOptions()}
/>
</div>
<div className='config-item'>
<span className=''>{_('Translation Service')}</span>
<Select
value={getCurrentTranslationProviderOption().value}
onChange={handleSelectTranslationProvider}
options={getTranslationProviderOptions()}
disabled={!translationEnabled}
/>
</div>
@@ -198,7 +228,6 @@ const LangPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterReset
value={getCurrentTargetLangOption().value}
onChange={handleSelectTargetLang}
options={getLangOptions(TRANSLATOR_LANGS)}
disabled={!translationEnabled}
/>
</div>
</div>
@@ -226,6 +226,7 @@ export const DEFAULT_TRANSLATOR_CONFIG: TranslatorConfig = {
translationProvider: 'deepl',
translateTargetLang: '',
showTranslateSource: true,
ttsReadAloudText: 'both',
};
export const DEFAULT_SCREEN_CONFIG: ScreenConfig = {
@@ -1,6 +1,6 @@
import { FoliateView } from '@/types/view';
import { AppService } from '@/types/system';
import { parseSSMLMarks } from '@/utils/ssml';
import { filterSSMLWithLang, parseSSMLMarks } from '@/utils/ssml';
import { Overlayer } from 'foliate-js/overlayer.js';
import { TTSGranularity, TTSHighlightOptions, TTSMark, TTSVoice } from './types';
import { createRejectFilter } from '@/utils/node';
@@ -39,6 +39,7 @@ export class TTSController extends EventTarget {
ttsWebVoices: TTSVoice[] = [];
ttsEdgeVoices: TTSVoice[] = [];
ttsNativeVoices: TTSVoice[] = [];
ttsTargetLang: string = '';
constructor(appService: AppService | null, view: FoliateView) {
super();
@@ -141,6 +142,9 @@ export class TTSController extends EventTarget {
.replace(/\*/g, ' ')
.replace(/·/g, ' ');
if (this.ttsTargetLang) {
ssml = filterSSMLWithLang(ssml, this.ttsTargetLang);
}
return ssml;
}
@@ -362,6 +366,10 @@ export class TTSController extends EventTarget {
return this.ttsClient.getSpeakingLang();
}
setTargetLang(lang: string) {
this.ttsTargetLang = lang;
}
dispatchSpeakMark(mark?: TTSMark) {
this.dispatchEvent(new CustomEvent('tts-speak-mark', { detail: mark || { text: '' } }));
if (mark) {
+1
View File
@@ -182,6 +182,7 @@ export interface TranslatorConfig {
translationProvider: string;
translateTargetLang: string;
showTranslateSource: boolean;
ttsReadAloudText: string;
}
export interface ScreenConfig {
-2
View File
@@ -1,7 +1,5 @@
import { RELOAD_BEFORE_SAVED_TIMEOUT_MS } from '@/services/constants';
import { eventDispatcher } from './event';
export const saveAndReload = async () => {
await eventDispatcher.dispatch('beforereload');
setTimeout(() => window.location.reload(), RELOAD_BEFORE_SAVED_TIMEOUT_MS);
};
+56
View File
@@ -116,3 +116,59 @@ export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
return result;
};
export const filterSSMLWithLang = (
ssml: string,
targetLang: string,
primaryLang?: string,
): string => {
const mainLang = parseSSMLLang(ssml, primaryLang);
// Normalize target language
const normalizedTarget = code6392to6391(targetLang.toLowerCase()) || targetLang.toLowerCase();
// Check if target matches main language
if (isSameLang(normalizedTarget, mainLang)) {
// Remove all <lang> blocks that don't match the main language
return ssml.replace(/<lang\s+xml:lang="([^"]+)"[^>]*>.*?<\/lang>/gs, (match, langAttr) => {
const blockLang = code6392to6391(langAttr.toLowerCase()) || langAttr.toLowerCase();
// If the lang block matches the main language, keep it as is
if (isSameLang(blockLang, mainLang)) {
return match;
}
// Otherwise remove the entire block
return '';
});
}
// Check if target matches any <lang> block
const langBlocks: Array<{ match: string; lang: string; content: string }> = [];
const langBlockRegex = /<lang\s+xml:lang="([^"]+)"[^>]*>(.*?)<\/lang>/gs;
let match: RegExpExecArray | null;
const tempRegex = new RegExp(langBlockRegex.source, langBlockRegex.flags);
while ((match = tempRegex.exec(ssml)) !== null) {
const blockLang = code6392to6391(match[1]!.toLowerCase()) || match[1]!.toLowerCase();
if (isSameLang(blockLang, normalizedTarget)) {
langBlocks.push({
match: match[0]!,
lang: match[1]!,
content: match[2]!,
});
}
}
if (langBlocks.length > 0) {
const speakOpenMatch = ssml.match(/<speak[^>]*>/i);
const speakCloseMatch = ssml.match(/<\/speak>/i);
if (!speakOpenMatch || !speakCloseMatch) {
return ssml;
}
const combinedContent = langBlocks.map((block) => block.match).join('');
return `${speakOpenMatch[0]}${combinedContent}${speakCloseMatch[0]}`;
}
return ssml;
};