From abb727870ec26b2d969e1c53119cc536cd4f1334 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 18 Jul 2025 15:27:51 +0800 Subject: [PATCH] tts: handle invalid language code and show no voices hints, closes #1579 (#1607) --- .../public/locales/ar/translation.json | 3 +- .../public/locales/de/translation.json | 3 +- .../public/locales/el/translation.json | 3 +- .../public/locales/es/translation.json | 3 +- .../public/locales/fr/translation.json | 3 +- .../public/locales/hi/translation.json | 3 +- .../public/locales/id/translation.json | 3 +- .../public/locales/it/translation.json | 3 +- .../public/locales/ja/translation.json | 3 +- .../public/locales/ko/translation.json | 3 +- .../public/locales/nl/translation.json | 3 +- .../public/locales/pl/translation.json | 3 +- .../public/locales/pt/translation.json | 3 +- .../public/locales/ru/translation.json | 3 +- .../public/locales/th/translation.json | 3 +- .../public/locales/tr/translation.json | 3 +- .../public/locales/uk/translation.json | 3 +- .../public/locales/vi/translation.json | 3 +- .../public/locales/zh-CN/translation.json | 5 +- .../public/locales/zh-TW/translation.json | 3 +- .../app/reader/components/tts/TTSControl.tsx | 6 +- .../app/reader/components/tts/TTSPanel.tsx | 93 ++++++++++--------- .../src/services/tts/EdgeTTSClient.ts | 17 +--- .../src/services/tts/NativeTTSClient.ts | 15 +-- .../src/services/tts/WebSpeechClient.ts | 18 +--- apps/readest-app/src/services/tts/types.ts | 2 +- apps/readest-app/src/utils/lang.ts | 25 +++++ apps/readest-app/src/utils/ssml.ts | 52 +++++------ 28 files changed, 157 insertions(+), 133 deletions(-) diff --git a/apps/readest-app/public/locales/ar/translation.json b/apps/readest-app/public/locales/ar/translation.json index cbfb011f..27328e6f 100644 --- a/apps/readest-app/public/locales/ar/translation.json +++ b/apps/readest-app/public/locales/ar/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/de/translation.json b/apps/readest-app/public/locales/de/translation.json index de581a93..40c51754 100644 --- a/apps/readest-app/public/locales/de/translation.json +++ b/apps/readest-app/public/locales/de/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/el/translation.json b/apps/readest-app/public/locales/el/translation.json index 18b78273..38237673 100644 --- a/apps/readest-app/public/locales/el/translation.json +++ b/apps/readest-app/public/locales/el/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/es/translation.json b/apps/readest-app/public/locales/es/translation.json index 67f01997..62fac3ed 100644 --- a/apps/readest-app/public/locales/es/translation.json +++ b/apps/readest-app/public/locales/es/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/fr/translation.json b/apps/readest-app/public/locales/fr/translation.json index 9da11321..7d14d6c0 100644 --- a/apps/readest-app/public/locales/fr/translation.json +++ b/apps/readest-app/public/locales/fr/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/hi/translation.json b/apps/readest-app/public/locales/hi/translation.json index 24644f13..1a1f9f80 100644 --- a/apps/readest-app/public/locales/hi/translation.json +++ b/apps/readest-app/public/locales/hi/translation.json @@ -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}} के लिए आवाज़ें" } diff --git a/apps/readest-app/public/locales/id/translation.json b/apps/readest-app/public/locales/id/translation.json index 98678962..98b9ad75 100644 --- a/apps/readest-app/public/locales/id/translation.json +++ b/apps/readest-app/public/locales/id/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/it/translation.json b/apps/readest-app/public/locales/it/translation.json index 281e34b8..a6697631 100644 --- a/apps/readest-app/public/locales/it/translation.json +++ b/apps/readest-app/public/locales/it/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/ja/translation.json b/apps/readest-app/public/locales/ja/translation.json index c59102a1..f8a1a9c4 100644 --- a/apps/readest-app/public/locales/ja/translation.json +++ b/apps/readest-app/public/locales/ja/translation.json @@ -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}}の音声" } diff --git a/apps/readest-app/public/locales/ko/translation.json b/apps/readest-app/public/locales/ko/translation.json index 6cbae69b..2763edfd 100644 --- a/apps/readest-app/public/locales/ko/translation.json +++ b/apps/readest-app/public/locales/ko/translation.json @@ -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}}의 음성" } diff --git a/apps/readest-app/public/locales/nl/translation.json b/apps/readest-app/public/locales/nl/translation.json index 53edb8c5..50935446 100644 --- a/apps/readest-app/public/locales/nl/translation.json +++ b/apps/readest-app/public/locales/nl/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/pl/translation.json b/apps/readest-app/public/locales/pl/translation.json index f1b6e93f..8e1bb4c8 100644 --- a/apps/readest-app/public/locales/pl/translation.json +++ b/apps/readest-app/public/locales/pl/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/pt/translation.json b/apps/readest-app/public/locales/pt/translation.json index 28c544cc..b8a1b682 100644 --- a/apps/readest-app/public/locales/pt/translation.json +++ b/apps/readest-app/public/locales/pt/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/ru/translation.json b/apps/readest-app/public/locales/ru/translation.json index 90d32819..863c8797 100644 --- a/apps/readest-app/public/locales/ru/translation.json +++ b/apps/readest-app/public/locales/ru/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/th/translation.json b/apps/readest-app/public/locales/th/translation.json index 031401ed..da8f0b74 100644 --- a/apps/readest-app/public/locales/th/translation.json +++ b/apps/readest-app/public/locales/th/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/tr/translation.json b/apps/readest-app/public/locales/tr/translation.json index 1c72f584..f6ab0390 100644 --- a/apps/readest-app/public/locales/tr/translation.json +++ b/apps/readest-app/public/locales/tr/translation.json @@ -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" } diff --git a/apps/readest-app/public/locales/uk/translation.json b/apps/readest-app/public/locales/uk/translation.json index 6169ce37..5c32ea03 100644 --- a/apps/readest-app/public/locales/uk/translation.json +++ b/apps/readest-app/public/locales/uk/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/vi/translation.json b/apps/readest-app/public/locales/vi/translation.json index b4455a66..5c2a0577 100644 --- a/apps/readest-app/public/locales/vi/translation.json +++ b/apps/readest-app/public/locales/vi/translation.json @@ -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}}" } diff --git a/apps/readest-app/public/locales/zh-CN/translation.json b/apps/readest-app/public/locales/zh-CN/translation.json index 6d1feb1c..351ebd19 100644 --- a/apps/readest-app/public/locales/zh-CN/translation.json +++ b/apps/readest-app/public/locales/zh-CN/translation.json @@ -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}} 的语音" } diff --git a/apps/readest-app/public/locales/zh-TW/translation.json b/apps/readest-app/public/locales/zh-TW/translation.json index 3f06d73a..f64ffdb7 100644 --- a/apps/readest-app/public/locales/zh-TW/translation.json +++ b/apps/readest-app/public/locales/zh-TW/translation.json @@ -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}} 的語音" } diff --git a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx index cffffe03..d698f384 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSControl.tsx @@ -212,11 +212,7 @@ const TTSControl: React.FC = ({ 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); diff --git a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx index 12344990..53f9aaab 100644 --- a/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx +++ b/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx @@ -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 && ( -
-
- - - {_('{{engine}}: {{count}} voices', { - engine: _(voiceGroup.name), - count: voiceGroup.voices.length, - })} - -
- {voiceGroup.voices.map((voice, voiceIndex) => ( -
  • !voice.disabled && handleSelectVoice(voice.id, voice.lang)} - > -
    - - {selectedVoice === voice.id && ( - - )} - - - {_(voice.name)} - -
    -
  • - ))} +
    +
    + + + {_('{{engine}}: {{count}} voices', { + engine: _(voiceGroup.name), + count: voiceGroup.voices.length, + })} +
    - ) + {voiceGroup.voices.map((voice, voiceIndex) => ( +
  • !voice.disabled && handleSelectVoice(voice.id, voice.lang)} + > +
    + + {selectedVoice === voice.id && } + + + {_(voice.name)} + +
    +
  • + ))} +
    ); })} diff --git a/apps/readest-app/src/services/tts/EdgeTTSClient.ts b/apps/readest-app/src/services/tts/EdgeTTSClient.ts index 5d127d18..f4e5e725 100644 --- a/apps/readest-app/src/services/tts/EdgeTTSClient.ts +++ b/apps/readest-app/src/services/tts/EdgeTTSClient.ts @@ -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( diff --git a/apps/readest-app/src/services/tts/NativeTTSClient.ts b/apps/readest-app/src/services/tts/NativeTTSClient.ts index 148a6f16..4a93b809 100644 --- a/apps/readest-app/src/services/tts/NativeTTSClient.ts +++ b/apps/readest-app/src/services/tts/NativeTTSClient.ts @@ -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; diff --git a/apps/readest-app/src/services/tts/WebSpeechClient.ts b/apps/readest-app/src/services/tts/WebSpeechClient.ts index fa58f383..798ca508 100644 --- a/apps/readest-app/src/services/tts/WebSpeechClient.ts +++ b/apps/readest-app/src/services/tts/WebSpeechClient.ts @@ -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, @@ -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, diff --git a/apps/readest-app/src/services/tts/types.ts b/apps/readest-app/src/services/tts/types.ts index e4091d3d..5f64bf4c 100644 --- a/apps/readest-app/src/services/tts/types.ts +++ b/apps/readest-app/src/services/tts/types.ts @@ -23,5 +23,5 @@ export type TTSMark = { offset: number; name: string; text: string; - language?: string; + language: string; }; diff --git a/apps/readest-app/src/utils/lang.ts b/apps/readest-app/src/utils/lang.ts index 83a2191e..e4dafc45 100644 --- a/apps/readest-app/src/utils/lang.ts +++ b/apps/readest-app/src/utils/lang.ts @@ -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; +}; diff --git a/apps/readest-app/src/utils/ssml.ts b/apps/readest-app/src/utils/ssml.ts index f2e5ac98..26b87c53 100644 --- a/apps/readest-app/src/utils/ssml.ts +++ b/apps/readest-app/src/utils/ssml.ts @@ -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(/]*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(/]*>/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); -};