|
3 | 3 | 'use strict'; |
4 | 4 |
|
5 | 5 | const STORAGE_KEY = 'hsk1-learned-words'; |
| 6 | + const DEFAULT_SPEECH_RATE = 0.65; |
| 7 | + const SLOW_SPEECH_RATE = 0.45; |
| 8 | + const MANDARIN_TTS = text => |
| 9 | + `https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(text)}&type=1`; |
| 10 | + |
6 | 11 | let learnedWords = loadLearned(); |
7 | 12 | let pronounceIndex = 0; |
8 | 13 | let quizState = null; |
9 | 14 | let chineseVoice = null; |
| 15 | + let currentAudio = null; |
10 | 16 |
|
11 | 17 | // --- Speech --- |
| 18 | + function pickMandarinVoice(voices) { |
| 19 | + const list = voices.filter(v => { |
| 20 | + const lang = (v.lang || '').toLowerCase(); |
| 21 | + if (lang === 'zh-cn') return true; |
| 22 | + if (!lang.startsWith('zh')) return false; |
| 23 | + return !/(tw|hk|mo)/.test(lang); |
| 24 | + }); |
| 25 | + |
| 26 | + const prefer = patterns => |
| 27 | + patterns.reduce((found, pattern) => { |
| 28 | + if (found) return found; |
| 29 | + return list.find(v => pattern.test(v.name) || pattern.test(v.lang)); |
| 30 | + }, null); |
| 31 | + |
| 32 | + return ( |
| 33 | + prefer([ |
| 34 | + /普通话|mandarin/i, |
| 35 | + /huihui|yaoyao|kangkang|tingting|yunxi|xiaoxiao|xiaoyi/i, |
| 36 | + /google.*中文|chinese.*china|zh-cn/i |
| 37 | + ]) || |
| 38 | + list.find(v => v.lang === 'zh-CN') || |
| 39 | + list[0] || |
| 40 | + null |
| 41 | + ); |
| 42 | + } |
| 43 | + |
12 | 44 | function initSpeech() { |
13 | 45 | if (!('speechSynthesis' in window)) return; |
14 | 46 |
|
15 | 47 | const loadVoices = () => { |
16 | | - const voices = speechSynthesis.getVoices(); |
17 | | - chineseVoice = |
18 | | - voices.find(v => v.lang === 'zh-CN') || |
19 | | - voices.find(v => v.lang.startsWith('zh')) || |
20 | | - null; |
| 48 | + chineseVoice = pickMandarinVoice(speechSynthesis.getVoices()); |
21 | 49 | }; |
22 | 50 |
|
23 | 51 | loadVoices(); |
24 | 52 | speechSynthesis.onvoiceschanged = loadVoices; |
25 | 53 | } |
26 | 54 |
|
27 | | - function speak(text, rate = 0.85) { |
28 | | - if (!('speechSynthesis' in window)) { |
29 | | - alert('Trình duyệt không hỗ trợ phát âm. Hãy thử Chrome hoặc Edge.'); |
30 | | - return; |
31 | | - } |
| 55 | + function rateToPlayback(speechRate) { |
| 56 | + return speechRate >= 0.55 ? 0.82 : 0.62; |
| 57 | + } |
| 58 | + |
| 59 | + function speakWithSynth(text, rate) { |
| 60 | + if (!('speechSynthesis' in window)) return null; |
32 | 61 |
|
33 | 62 | speechSynthesis.cancel(); |
34 | 63 | const utterance = new SpeechSynthesisUtterance(text); |
35 | 64 | utterance.lang = 'zh-CN'; |
36 | 65 | utterance.rate = rate; |
| 66 | + utterance.pitch = 1; |
| 67 | + chineseVoice = pickMandarinVoice(speechSynthesis.getVoices()) || chineseVoice; |
37 | 68 | if (chineseVoice) utterance.voice = chineseVoice; |
38 | 69 | speechSynthesis.speak(utterance); |
39 | 70 | return utterance; |
40 | 71 | } |
41 | 72 |
|
| 73 | + function speak(text, rate = DEFAULT_SPEECH_RATE) { |
| 74 | + speechSynthesis.cancel(); |
| 75 | + if (currentAudio) { |
| 76 | + currentAudio.pause(); |
| 77 | + currentAudio = null; |
| 78 | + } |
| 79 | + |
| 80 | + const audio = new Audio(MANDARIN_TTS(text)); |
| 81 | + audio.playbackRate = rateToPlayback(rate); |
| 82 | + currentAudio = audio; |
| 83 | + |
| 84 | + audio.play().catch(() => { |
| 85 | + if (!('speechSynthesis' in window)) { |
| 86 | + alert('Trình duyệt không hỗ trợ phát âm. Hãy thử Chrome hoặc Edge.'); |
| 87 | + return; |
| 88 | + } |
| 89 | + speakWithSynth(text, rate); |
| 90 | + }); |
| 91 | + |
| 92 | + return audio; |
| 93 | + } |
| 94 | + |
42 | 95 | function flashAudioBtn(btn) { |
43 | 96 | if (!btn) return; |
44 | 97 | btn.classList.add('playing'); |
|
160 | 213 | updatePronounceCard(); |
161 | 214 |
|
162 | 215 | document.getElementById('btn-listen')?.addEventListener('click', e => { |
163 | | - speak(HSK1_VOCABULARY[pronounceIndex].hanzi, 0.85); |
| 216 | + speak(HSK1_VOCABULARY[pronounceIndex].hanzi, DEFAULT_SPEECH_RATE); |
164 | 217 | flashAudioBtn(e.currentTarget); |
165 | 218 | }); |
166 | 219 |
|
167 | 220 | document.getElementById('btn-slow')?.addEventListener('click', e => { |
168 | | - speak(HSK1_VOCABULARY[pronounceIndex].hanzi, 0.55); |
| 221 | + speak(HSK1_VOCABULARY[pronounceIndex].hanzi, SLOW_SPEECH_RATE); |
169 | 222 | flashAudioBtn(e.currentTarget); |
170 | 223 | }); |
171 | 224 |
|
|
303 | 356 | } |
304 | 357 |
|
305 | 358 | // --- Init --- |
| 359 | + function updateVocabTotal() { |
| 360 | + const total = HSK1_VOCABULARY.length; |
| 361 | + const vocabTotal = document.getElementById('vocab-total'); |
| 362 | + const pronounceTotal = document.getElementById('pronounce-total'); |
| 363 | + if (vocabTotal) vocabTotal.textContent = total; |
| 364 | + if (pronounceTotal) pronounceTotal.textContent = total; |
| 365 | + } |
| 366 | + |
306 | 367 | function init() { |
307 | 368 | initSpeech(); |
308 | 369 | setupNavigation(); |
|
311 | 372 | setupPronounce(); |
312 | 373 | setupQuiz(); |
313 | 374 | updateProgressBadge(); |
| 375 | + updateVocabTotal(); |
314 | 376 | if (window.HSK1Surprise) window.HSK1Surprise.init(); |
315 | 377 | } |
316 | 378 |
|
|
0 commit comments