Add TTS and pronunciation display to vocabulary games

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 <noreply@anthropic.com>
This commit is contained in:
StillHammer 2025-10-15 07:23:47 +08:00
parent 838c8289b8
commit 7a18e27a44
2 changed files with 151 additions and 11 deletions

View File

@ -1364,19 +1364,22 @@ class FlashcardLearning extends Module {
">
${this._currentCard.displayFront}
</div>
<div style="
<div id="answer-tts" style="
font-size: 48px;
font-weight: bold;
margin-bottom: 20px;
text-align: center;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
">
cursor: pointer;
transition: all 0.2s ease;
" title="Click to hear pronunciation">
${this._currentCard.displayBack}
</div>
<div style="
<div id="pronunciation-display" style="
font-size: 18px;
opacity: 0.8;
font-style: italic;
transition: all 0.3s ease;
">
${this._currentCard.pronunciation || ''}
</div>
@ -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 '';

View File

@ -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 `
<div class="quiz-option" data-option="${index}" data-value="${optionText}">
<div class="quiz-option"
data-option="${index}"
data-value="${optionText}"
data-word="${chineseWord}"
data-pronunciation="${pronunciation}"
title="Click to hear pronunciation">
${optionText}
${pronunciation ? `<div class="option-pronunciation">[${pronunciation}]</div>` : ''}
</div>
`;
}).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';