From 7a18e27a4427a9e1bacbd45120fa229b31ee313b Mon Sep 17 00:00:00 2001 From: StillHammer Date: Wed, 15 Oct 2025 07:23:47 +0800 Subject: [PATCH] Add TTS and pronunciation display to vocabulary games MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement click-to-speak functionality with visual pronunciation feedback in QuizGame and FlashcardLearning. When users click on vocabulary options or answers, the system plays native language audio (e.g., Chinese) and highlights the pronunciation (pinyin) with animation. Features: - TTS uses chapter language (zh-CN, en-US, etc.) for correct pronunciation - Pronunciation text displayed under each quiz option - Click on answer triggers TTS + 2s highlight animation - Hover effects on clickable elements - Auto-detect and use matching voice from speechSynthesis API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/games/FlashcardLearning.js | 73 ++++++++++++++++++++++++---- src/games/QuizGame.js | 89 +++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 11 deletions(-) diff --git a/src/games/FlashcardLearning.js b/src/games/FlashcardLearning.js index 305a502..36320fd 100644 --- a/src/games/FlashcardLearning.js +++ b/src/games/FlashcardLearning.js @@ -1364,19 +1364,22 @@ class FlashcardLearning extends Module { "> ${this._currentCard.displayFront} -
+ cursor: pointer; + transition: all 0.2s ease; + " title="Click to hear pronunciation"> ${this._currentCard.displayBack}
-
${this._currentCard.pronunciation || ''}
@@ -1388,6 +1391,23 @@ class FlashcardLearning extends Module { this._isRevealed = true; + // Add click listener on answer for TTS + const answerTTS = document.getElementById('answer-tts'); + if (answerTTS) { + answerTTS.addEventListener('click', () => { + this._playAudio(this._currentCard.front); + this._highlightPronunciation(); + }); + answerTTS.addEventListener('mouseenter', () => { + answerTTS.style.transform = 'scale(1.05)'; + answerTTS.style.color = '#6dd5fa'; + }); + answerTTS.addEventListener('mouseleave', () => { + answerTTS.style.transform = 'scale(1)'; + answerTTS.style.color = 'white'; + }); + } + // Add difficulty buttons in game-controls section const gameControls = document.querySelector('.game-controls'); if (gameControls) { @@ -1512,6 +1532,7 @@ class FlashcardLearning extends Module { // Always play audio for pronunciation, regardless of mode setTimeout(() => { this._playAudio(this._currentCard.front); + this._highlightPronunciation(); }, 200); }, 150); } @@ -1712,24 +1733,58 @@ class FlashcardLearning extends Module { speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); + + // Get language from chapter content, fallback to en-US + const chapterLanguage = this._content?.language || 'en-US'; + utterance.lang = chapterLanguage; utterance.rate = 0.8; utterance.pitch = 1.0; utterance.volume = 1.0; - // Try to use a good English voice + // Try to find a suitable voice for the language const voices = speechSynthesis.getVoices(); - const englishVoice = voices.find(voice => - voice.lang.startsWith('en') && voice.name.includes('Neural') - ) || voices.find(voice => voice.lang.startsWith('en')); + if (voices.length > 0) { + // Find voice matching the chapter language + const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN" + const matchingVoice = voices.find(voice => + voice.lang.startsWith(langPrefix) && (voice.name.includes('Neural') || voice.default) + ) || voices.find(voice => voice.lang.startsWith(langPrefix)); - if (englishVoice) { - utterance.voice = englishVoice; + if (matchingVoice) { + utterance.voice = matchingVoice; + console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang); + } } speechSynthesis.speak(utterance); } } + _highlightPronunciation() { + // Highlight pronunciation when TTS is played + const pronunciation = document.getElementById('pronunciation-display'); + + if (pronunciation) { + // Store original styles + const originalColor = pronunciation.style.color; + const originalFontSize = pronunciation.style.fontSize; + + // Add highlight + pronunciation.style.color = '#6dd5fa'; + pronunciation.style.fontWeight = 'bold'; + pronunciation.style.fontSize = '22px'; + pronunciation.style.transform = 'scale(1.1)'; + + // Remove highlight after animation + setTimeout(() => { + pronunciation.style.color = originalColor; + pronunciation.style.fontWeight = 'normal'; + pronunciation.style.fontSize = originalFontSize; + pronunciation.style.transform = 'scale(1)'; + }, 2000); + } + } + _generatePronunciation(word) { if (!word || typeof word !== 'string') return ''; diff --git a/src/games/QuizGame.js b/src/games/QuizGame.js index 70b02b5..a6a2be3 100644 --- a/src/games/QuizGame.js +++ b/src/games/QuizGame.js @@ -190,7 +190,8 @@ class QuizGame extends Module { vocabulary.push({ english: word, translation: data.user_language, - type: data.type || 'unknown' + type: data.type || 'unknown', + pronunciation: data.pronunciation || '' }); } } @@ -396,6 +397,14 @@ class QuizGame extends Module { opacity: 0.7; } + .option-pronunciation { + font-size: 0.85rem; + font-style: italic; + color: #6c757d; + margin-top: 8px; + transition: all 0.3s ease; + } + .quiz-feedback { text-align: center; padding: 20px; @@ -692,9 +701,19 @@ class QuizGame extends Module { ? vocab.translation : vocab.english; + // Store the Chinese word and pronunciation for TTS + const chineseWord = vocab.english; // In our data structure, english is the Chinese word + const pronunciation = vocab.pronunciation || ''; + return ` -
+
${optionText} + ${pronunciation ? `
[${pronunciation}]
` : ''}
`; }).join(''); @@ -765,6 +784,16 @@ class QuizGame extends Module { return; } + // Play TTS when clicking on an option + const word = optionElement.dataset.word; + const pronunciation = optionElement.dataset.pronunciation; + if (word) { + this._playAudio(word); + if (pronunciation) { + this._highlightPronunciation(optionElement); + } + } + this._isAnswering = true; const question = this._questions[this._currentQuestion]; const selectedAnswer = optionElement.dataset.value; @@ -962,6 +991,62 @@ class QuizGame extends Module { this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); } + _playAudio(text) { + if ('speechSynthesis' in window) { + // Cancel any ongoing speech + speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + + // Get language from chapter content, fallback to en-US + const chapterLanguage = this._content?.language || 'en-US'; + utterance.lang = chapterLanguage; + utterance.rate = 0.8; + utterance.pitch = 1.0; + utterance.volume = 1.0; + + // Try to find a suitable voice for the language + const voices = speechSynthesis.getVoices(); + if (voices.length > 0) { + // Find voice matching the chapter language + const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN" + const matchingVoice = voices.find(voice => + voice.lang.startsWith(langPrefix) && (voice.name.includes('Neural') || voice.default) + ) || voices.find(voice => voice.lang.startsWith(langPrefix)); + + if (matchingVoice) { + utterance.voice = matchingVoice; + console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang); + } + } + + speechSynthesis.speak(utterance); + } + } + + _highlightPronunciation(optionElement) { + const pronunciation = optionElement.querySelector('.option-pronunciation'); + + if (pronunciation) { + // Store original styles + const originalColor = pronunciation.style.color; + const originalFontWeight = pronunciation.style.fontWeight; + + // Add highlight + pronunciation.style.color = '#007bff'; + pronunciation.style.fontWeight = 'bold'; + pronunciation.style.transform = 'scale(1.2)'; + pronunciation.style.transition = 'all 0.3s ease'; + + // Remove highlight after animation + setTimeout(() => { + pronunciation.style.color = originalColor; + pronunciation.style.fontWeight = originalFontWeight; + pronunciation.style.transform = 'scale(1)'; + }, 2000); + } + } + _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { const popup = document.createElement('div'); popup.className = 'victory-popup';