tts: handle invalid language code and show no voices hints, closes #1579 (#1607)

This commit is contained in:
Huang Xin
2025-07-18 15:27:51 +08:00
committed by GitHub
parent af84a8b110
commit abb727870e
28 changed files with 157 additions and 133 deletions
@@ -466,5 +466,6 @@
"Open Library": "المكتبة المفتوحة",
"Fiction, Science, History": "خيال، علوم، تاريخ",
"Select Cover Image": "اختر صورة الغلاف",
"Open Book in New Window": "فتح الكتاب في نافذة جديدة"
"Open Book in New Window": "فتح الكتاب في نافذة جديدة",
"Voices for {{lang}}": "الأصوات لـ {{lang}}"
}
@@ -450,5 +450,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Belletristik, Wissenschaft, Geschichte",
"Select Cover Image": "Coverbild auswählen",
"Open Book in New Window": "Buch in neuem Fenster öffnen"
"Open Book in New Window": "Buch in neuem Fenster öffnen",
"Voices for {{lang}}": "Stimmen für {{lang}}"
}
@@ -450,5 +450,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Μυθοπλασία, Επιστήμη, Ιστορία",
"Select Cover Image": "Επιλέξτε εικόνα εξωφύλλου",
"Open Book in New Window": "Άνοιγμα βιβλίου σε νέο παράθυρο"
"Open Book in New Window": "Άνοιγμα βιβλίου σε νέο παράθυρο",
"Voices for {{lang}}": "Φωνές για {{lang}}"
}
@@ -454,5 +454,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Ficción, Ciencia, Historia",
"Select Cover Image": "Seleccionar imagen de portada",
"Open Book in New Window": "Abre el libro en una nueva ventana"
"Open Book in New Window": "Abre el libro en una nueva ventana",
"Voices for {{lang}}": "Voces para {{lang}}"
}
@@ -454,5 +454,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Fiction, Science, Histoire",
"Select Cover Image": "Sélectionner l'image de couverture",
"Open Book in New Window": "Ouvrir dans une nouvelle fenêtre"
"Open Book in New Window": "Ouvrir dans une nouvelle fenêtre",
"Voices for {{lang}}": "Voix pour {{lang}}"
}
@@ -450,5 +450,6 @@
"Open Library": "ओपन लाइब्रेरी",
"Fiction, Science, History": "काल्पनिक, विज्ञान, इतिहास",
"Select Cover Image": "कवर इमेज चुनें",
"Open Book in New Window": "नई विंडो में पुस्तक खोलें"
"Open Book in New Window": "नई विंडो में पुस्तक खोलें",
"Voices for {{lang}}": "{{lang}} के लिए आवाज़ें"
}
@@ -446,5 +446,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Fiksi, Sains, Sejarah",
"Select Cover Image": "Pilihan Gambar Sampul",
"Open Book in New Window": "Buka Buku di Jendela Baru"
"Open Book in New Window": "Buka Buku di Jendela Baru",
"Voices for {{lang}}": "Suara untuk {{lang}}"
}
@@ -454,5 +454,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Narrativa, Scienza, Storia",
"Select Cover Image": "Seleziona immagine di copertina",
"Open Book in New Window": "Apri libro in una nuova finestra"
"Open Book in New Window": "Apri libro in una nuova finestra",
"Voices for {{lang}}": "Voci per {{lang}}"
}
@@ -446,5 +446,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "小説、科学、歴史",
"Select Cover Image": "表紙画像を選択",
"Open Book in New Window": "新しいウィンドウで本を開く"
"Open Book in New Window": "新しいウィンドウで本を開く",
"Voices for {{lang}}": "{{lang}}の音声"
}
@@ -446,5 +446,6 @@
"Open Library": "오픈 라이브러리",
"Fiction, Science, History": "소설, 과학, 역사",
"Select Cover Image": "표지 이미지 선택",
"Open Book in New Window": "새 창에서 책 열기"
"Open Book in New Window": "새 창에서 책 열기",
"Voices for {{lang}}": "{{lang}}의 음성"
}
@@ -450,5 +450,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Fictie, Wetenschap, Geschiedenis",
"Select Cover Image": "Selecteer omslagafbeelding",
"Open Book in New Window": "Open boek in nieuw venster"
"Open Book in New Window": "Open boek in nieuw venster",
"Voices for {{lang}}": "Stemmen voor {{lang}}"
}
@@ -458,5 +458,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Beletrystyka, Nauka, Historia",
"Select Cover Image": "Zaznacz obraz okładki",
"Open Book in New Window": "Otwórz książkę w nowym oknie"
"Open Book in New Window": "Otwórz książkę w nowym oknie",
"Voices for {{lang}}": "{{lang}} Głosy"
}
@@ -454,5 +454,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Ficção, Ciência, História",
"Select Cover Image": "Selecionar imagem da capa",
"Open Book in New Window": "Abrir livro em nova janela"
"Open Book in New Window": "Abrir livro em nova janela",
"Voices for {{lang}}": "Vozes para {{lang}}"
}
@@ -458,5 +458,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Художественная литература, Наука, История",
"Select Cover Image": "Выбрать изображение обложки",
"Open Book in New Window": "Открыть книгу в новом окне"
"Open Book in New Window": "Открыть книгу в новом окне",
"Voices for {{lang}}": "Голоса для {{lang}}"
}
@@ -446,5 +446,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "นิยาย, วิทยาศาสตร์, ประวัติศาสตร์",
"Select Cover Image": "เลือกภาพปก",
"Open Book in New Window": "เปิดหนังสือในหน้าต่างใหม่"
"Open Book in New Window": "เปิดหนังสือในหน้าต่างใหม่",
"Voices for {{lang}}": "เสียงสำหรับ {{lang}}"
}
@@ -450,5 +450,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Kurgu, Bilim, Tarih",
"Select Cover Image": "Kapak Resmi Seç",
"Open Book in New Window": "Kitabı Yeni Pencerede Aç"
"Open Book in New Window": "Kitabı Yeni Pencerede Aç",
"Voices for {{lang}}": "{{lang}} için Sesler"
}
@@ -458,5 +458,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Художня література, Наукова фантастика, Історія",
"Select Cover Image": "Оберіть зображення обкладинки",
"Open Book in New Window": "Відкрити книгу в новому вікні"
"Open Book in New Window": "Відкрити книгу в новому вікні",
"Voices for {{lang}}": "Голоси для {{lang}}"
}
@@ -446,5 +446,6 @@
"Open Library": "Open Library",
"Fiction, Science, History": "Tiểu thuyết, Khoa học, Lịch sử",
"Select Cover Image": "Chọn ảnh bìa",
"Open Book in New Window": "Open Sách trong Cửa sổ Mới"
"Open Book in New Window": "Open Sách trong Cửa sổ Mới",
"Voices for {{lang}}": "Giọng nói cho {{lang}}"
}
@@ -317,7 +317,7 @@
"Quota Exceeded": "额度不足",
"{{percentage}}% of Daily Translation Characters Used.": "已使用 {{percentage}}% 的每日翻译字符数",
"Translation Characters": "翻译字数",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种音",
"{{engine}}: {{count}} voices_other": "{{engine}}: {{count}} 种音",
"Something went wrong. Don't worry, our team has been notified and we're working on a fix.": "出了点问题。请不要担心,我们的团队已经收到通知并正在修复中。",
"Error Details:": "错误详情:",
"Try Again": "重试",
@@ -446,5 +446,6 @@
"Open Library": "开放图书馆",
"Fiction, Science, History": "小说,科学,历史",
"Select Cover Image": "选择封面图片",
"Open Book in New Window": "在新窗口中打开书籍"
"Open Book in New Window": "在新窗口中打开书籍",
"Voices for {{lang}}": "{{lang}} 的语音"
}
@@ -446,5 +446,6 @@
"Open Library": "開放圖書館",
"Fiction, Science, History": "小說,科學,歷史",
"Select Cover Image": "選擇封面圖片",
"Open Book in New Window": "在新視窗中打開書籍"
"Open Book in New Window": "在新視窗中打開書籍",
"Voices for {{lang}}": "{{lang}} 的語音"
}
@@ -212,11 +212,7 @@ const TTSControl: React.FC<TTSControlProps> = ({ bookKey }) => {
await ttsController.initViewTTS();
const ssml = view.tts?.from(ttsFromRange);
if (ssml) {
let lang = parseSSMLLang(ssml) || 'en';
// We will not trust 'en' language from ssml, as it may be a fallback or hardcoded value
if (lang === 'en' && primaryLang && primaryLang !== 'en') {
lang = primaryLang.split('-')[0]!;
}
let lang = parseSSMLLang(ssml, primaryLang) || 'en';
setIsPlaying(true);
setTtsLang(lang);
@@ -9,6 +9,7 @@ import { useReaderStore } from '@/store/readerStore';
import { TranslationFunc, useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
import { getLanguageName } from '@/utils/lang';
type TTSPanelProps = {
bookKey: string;
@@ -178,7 +179,19 @@ const TTSPanel = ({
useEffect(() => {
const fetchVoices = async () => {
const voiceGroups = await onGetVoices(ttsLang);
setVoiceGroups(voiceGroups);
const voicesCount = voiceGroups.reduce((acc, group) => acc + group.voices.length, 0);
if (!voiceGroups || voicesCount === 0) {
console.warn('No voices found for TTSPanel');
setVoiceGroups([
{
id: 'no-voices',
name: _('Voices for {{lang}}', { lang: getLanguageName(ttsLang) }),
voices: [],
},
]);
} else {
setVoiceGroups(voiceGroups);
}
};
fetchVoices();
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -289,48 +302,44 @@ const TTSPanel = ({
>
{voiceGroups.map((voiceGroup, index) => {
return (
voiceGroup.voices.length > 0 && (
<div key={voiceGroup.id} className=''>
<div className='flex items-center gap-2 px-2 py-1'>
<span
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
></span>
<span className='text-sm text-gray-400 sm:text-xs'>
{_('{{engine}}: {{count}} voices', {
engine: _(voiceGroup.name),
count: voiceGroup.voices.length,
})}
</span>
</div>
{voiceGroup.voices.map((voice, voiceIndex) => (
<li
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
>
<div className='flex items-center px-2'>
<span
style={{
width: `${defaultIconSize}px`,
height: `${defaultIconSize}px`,
}}
>
{selectedVoice === voice.id && (
<MdCheck className='text-base-content' />
)}
</span>
<span
className={clsx(
'text-base sm:text-sm',
voice.disabled && 'text-gray-400',
)}
>
{_(voice.name)}
</span>
</div>
</li>
))}
<div key={voiceGroup.id} className=''>
<div className='flex items-center gap-2 px-2 py-1'>
<span
style={{ width: `${defaultIconSize}px`, height: `${defaultIconSize}px` }}
></span>
<span className='text-sm text-gray-400 sm:text-xs'>
{_('{{engine}}: {{count}} voices', {
engine: _(voiceGroup.name),
count: voiceGroup.voices.length,
})}
</span>
</div>
)
{voiceGroup.voices.map((voice, voiceIndex) => (
<li
key={`${index}-${voiceGroup.id}-${voiceIndex}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id, voice.lang)}
>
<div className='flex items-center px-2'>
<span
style={{
width: `${defaultIconSize}px`,
height: `${defaultIconSize}px`,
}}
>
{selectedVoice === voice.id && <MdCheck className='text-base-content' />}
</span>
<span
className={clsx(
'text-base sm:text-sm',
voice.disabled && 'text-gray-400',
)}
>
{_(voice.name)}
</span>
</div>
</li>
))}
</div>
);
})}
</ul>
@@ -1,7 +1,7 @@
import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent } from './TTSClient';
import { EdgeSpeechTTS, EdgeTTSPayload } from '@/libs/edgeTTS';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { parseSSMLMarks } from '@/utils/ssml';
import { TTSController } from './TTSController';
import { TTSUtils } from './TTSUtils';
import { TTSGranularity, TTSVoice, TTSVoicesGroup } from './types';
@@ -60,19 +60,14 @@ export class EdgeTTSClient implements TTSClient {
};
async *speak(ssml: string, signal: AbortSignal, preload = false) {
const { marks } = parseSSMLMarks(ssml);
let defaultLang = parseSSMLLang(ssml) || 'en';
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
defaultLang = this.#primaryLang;
}
const { marks } = parseSSMLMarks(ssml, this.#primaryLang);
if (preload) {
// preload the first 2 marks immediately and the rest in the background
const maxImmediate = 2;
for (let i = 0; i < Math.min(maxImmediate, marks.length); i++) {
const mark = marks[i]!;
const { language } = mark;
const voiceLang = language || defaultLang;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#currentVoiceId = voiceId;
await this.#edgeTTS
@@ -86,8 +81,7 @@ export class EdgeTTSClient implements TTSClient {
for (let i = maxImmediate; i < marks.length; i++) {
const mark = marks[i]!;
try {
const { language } = mark;
const voiceLang = language || defaultLang;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
await this.#edgeTTS.createAudio(this.getPayload(voiceLang, mark.text, voiceId));
} catch (err) {
@@ -116,8 +110,7 @@ export class EdgeTTSClient implements TTSClient {
break;
}
try {
const { language } = mark;
const voiceLang = language || defaultLang;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#speakingLang = voiceLang;
const blob = await this.#edgeTTS.createAudio(
@@ -1,7 +1,7 @@
import { invoke } from '@tauri-apps/api/core';
import { addPluginListener, PluginListener } from '@tauri-apps/api/core';
import { getUserLocale } from '@/utils/misc';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { parseSSMLMarks } from '@/utils/ssml';
import { stubTranslation as _ } from '@/utils/misc';
import { TTSClient, TTSMessageEvent } from './TTSClient';
import { TTSGranularity, TTSMark, TTSVoice, TTSVoicesGroup } from './types';
@@ -100,13 +100,12 @@ export class NativeTTSClient implements TTSClient {
return this.initialized;
}
async *speakMark(mark: TTSMark, preload: boolean, defaultLang: string, signal: AbortSignal) {
async *speakMark(mark: TTSMark, preload: boolean, signal: AbortSignal) {
if (preload) {
yield { code: 'end', message: 'Dummy preload finished' } as TTSMessageEvent;
return;
}
const { language } = mark;
const voiceLang = language || defaultLang;
const { language: voiceLang } = mark;
const voiceId = await this.getVoiceIdFromLang(voiceLang);
this.#currentVoiceId = voiceId;
this.#speakingLang = voiceLang;
@@ -179,14 +178,10 @@ export class NativeTTSClient implements TTSClient {
}
async *speak(ssml: string, signal: AbortSignal, preload: boolean = false) {
const { marks } = parseSSMLMarks(ssml);
let defaultLang = parseSSMLLang(ssml) || 'en';
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
defaultLang = this.#primaryLang;
}
const { marks } = parseSSMLMarks(ssml, this.#primaryLang);
for (const mark of marks) {
for await (const ev of this.speakMark(mark, preload, defaultLang, signal)) {
for await (const ev of this.speakMark(mark, preload, signal)) {
if (signal.aborted) {
yield { code: 'error', message: 'Aborted' } as TTSMessageEvent;
return;
@@ -1,6 +1,6 @@
import { getUserLocale } from '@/utils/misc';
import { TTSClient, TTSMessageEvent } from './TTSClient';
import { parseSSMLLang, parseSSMLMarks } from '@/utils/ssml';
import { parseSSMLMarks } from '@/utils/ssml';
import { TTSGranularity, TTSMark, TTSVoice, TTSVoicesGroup } from './types';
import { WEB_SPEECH_BLACKLISTED_VOICES } from './TTSData';
import { TTSController } from './TTSController';
@@ -18,7 +18,7 @@ interface TTSBoundaryEvent {
async function* speakWithMarks(
ssml: string,
defaultLang: string,
primaryLang: string,
getRate: () => number,
getPitch: () => number,
getVoice: (lang: string) => Promise<SpeechSynthesisVoice | null>,
@@ -26,14 +26,11 @@ async function* speakWithMarks(
setSpeakingLang: (lang: string) => void,
dispatchSpeakMark: (mark: TTSMark) => void,
) {
const { marks } = parseSSMLMarks(ssml);
const { marks } = parseSSMLMarks(ssml, primaryLang);
const synth = window.speechSynthesis;
const utterance = new SpeechSynthesisUtterance();
for (const mark of marks) {
const { language } = mark;
const voiceLang = language || defaultLang;
const { language: voiceLang } = mark;
dispatchSpeakMark(mark);
utterance.text = mark.text;
utterance.rate = getRate();
@@ -149,14 +146,9 @@ export class WebSpeechClient implements TTSClient {
// no need to preload for web speech
if (preload) return;
let defaultLang = parseSSMLLang(ssml) || 'en';
if (defaultLang === 'en' && this.#primaryLang && this.#primaryLang !== 'en') {
defaultLang = this.#primaryLang;
}
for await (const ev of speakWithMarks(
ssml,
defaultLang,
this.#primaryLang,
() => this.#rate,
() => this.#pitch,
this.getWebSpeechVoiceFromLang,
+1 -1
View File
@@ -23,5 +23,5 @@ export type TTSMark = {
offset: number;
name: string;
text: string;
language?: string;
language: string;
};
+25
View File
@@ -68,7 +68,32 @@ export const isSameLang = (lang1?: string | null, lang2?: string | null): boolea
return normalizedLang1 === normalizedLang2;
};
export const isValidLang = (lang?: string) => {
if (!lang) return false;
const code = lang.split('-')[0]!.toLowerCase();
return iso6392.some((l) => l.iso6391 === code || l.iso6392B === code);
};
export const code6392to6391 = (code: string): string => {
const lang = iso6392.find((l) => l.iso6392B === code);
return lang?.iso6391 || '';
};
export const getLanguageName = (code: string): string => {
const lang = normalizedLangCode(code);
const language = iso6392.find((l) => l.iso6391 === lang || l.iso6392B === lang);
return language ? language.name : lang;
};
export const inferLangFromScript = (text: string, lang: string): string => {
if (!lang || lang === 'en') {
if (/[\p{Script=Hangul}]/u.test(text)) {
return 'ko';
} else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) {
return 'ja';
} else if (/[\p{Script=Han}]/u.test(text)) {
return 'zh';
}
}
return lang;
};
+23 -29
View File
@@ -1,4 +1,5 @@
import { TTSMark } from '@/services/tts/types';
import { inferLangFromScript, isSameLang, isValidLang } from './lang';
const cleanTextContent = (text: string) =>
text.replace(/\r\n/g, ' ').replace(/\r/g, ' ').replace(/\n/g, ' ').trimStart();
@@ -16,9 +17,28 @@ export const genSSML = (lang: string, text: string, voice: string, rate: number)
`;
};
export const parseSSMLMarks = (ssml: string) => {
const speakMatch = ssml.match(/<speak[^>]*xml:lang="([^"]+)"[^>]*>/i);
const defaultLang = speakMatch?.[1] ?? 'en';
export const parseSSMLLang = (ssml: string, primaryLang?: string): string => {
let lang = 'en';
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
if (match && match[1]) {
const parts = match[1].split('-');
lang =
parts.length > 1
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
: parts[0]!.toLowerCase();
if (!isValidLang(lang)) {
lang = 'en';
}
}
if (lang === 'en' && primaryLang && !isSameLang(lang, primaryLang)) {
lang = primaryLang.split('-')[0]!.toLowerCase();
}
return inferLangFromScript(ssml, lang);
};
export const parseSSMLMarks = (ssml: string, primaryLang?: string) => {
const defaultLang = parseSSMLLang(ssml, primaryLang) || 'en';
ssml = ssml.replace(/<speak[^>]*>/i, '').replace(/<\/speak>/i, '');
let plainText = '';
@@ -93,29 +113,3 @@ export const findSSMLMark = (charIndex: number, marks: TTSMark[]) => {
return result;
};
const inferLangFromScript = (text: string, lang: string | null): string | null => {
if (!lang || lang.startsWith('en')) {
if (/[\p{Script=Hangul}]/u.test(text)) {
return 'ko';
} else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) {
return 'ja';
} else if (/[\p{Script=Han}]/u.test(text)) {
return 'zh';
}
}
return lang;
};
export const parseSSMLLang = (ssml: string): string | null => {
let lang = null;
const match = ssml.match(/xml:lang\s*=\s*"([^"]+)"/);
if (match && match[1]) {
const parts = match[1].split('-');
lang =
parts.length > 1
? `${parts[0]!.toLowerCase()}-${parts[1]!.toUpperCase()}`
: parts[0]!.toLowerCase();
}
return inferLangFromScript(ssml, lang);
};