Spaces:
Sleeping
Sleeping
Enhance Vietnamese feedback generation with actionable insights and specific improvement strategies. Refine overall feedback based on score ranges, provide detailed guidance for problematic words and phonemes, and suggest clear next steps for users to improve their pronunciation skills.
b9c5d04
| from typing import List, Dict | |
| import numpy as np | |
| import nltk | |
| import eng_to_ipa as ipa | |
| import re | |
| from collections import defaultdict | |
| try: | |
| nltk.download("cmudict", quiet=True) | |
| from nltk.corpus import cmudict | |
| except: | |
| print("Warning: NLTK data not available") | |
| class SimpleG2P: | |
| """Simple Grapheme-to-Phoneme converter for reference text""" | |
| def __init__(self): | |
| try: | |
| self.cmu_dict = cmudict.dict() | |
| except: | |
| self.cmu_dict = {} | |
| print("Warning: CMU dictionary not available") | |
| def text_to_phonemes(self, text: str) -> List[Dict]: | |
| """Convert text to phoneme sequence""" | |
| words = self._clean_text(text).split() | |
| phoneme_sequence = [] | |
| for word in words: | |
| word_phonemes = self._get_word_phonemes(word) | |
| phoneme_sequence.append( | |
| { | |
| "word": word, | |
| "phonemes": word_phonemes, | |
| "ipa": self._get_ipa(word), | |
| "phoneme_string": " ".join(word_phonemes), | |
| } | |
| ) | |
| return phoneme_sequence | |
| def get_reference_phoneme_string(self, text: str) -> str: | |
| """Get reference phoneme string for comparison""" | |
| phoneme_sequence = self.text_to_phonemes(text) | |
| all_phonemes = [] | |
| for word_data in phoneme_sequence: | |
| all_phonemes.extend(word_data["phonemes"]) | |
| return " ".join(all_phonemes) | |
| def _clean_text(self, text: str) -> str: | |
| """Clean text for processing""" | |
| text = re.sub(r"[^\w\s\']", " ", text) | |
| text = re.sub(r"\s+", " ", text) | |
| return text.lower().strip() | |
| def _get_word_phonemes(self, word: str) -> List[str]: | |
| """Get phonemes for a word""" | |
| word_lower = word.lower() | |
| if word_lower in self.cmu_dict: | |
| # Remove stress markers and convert to Wav2Vec2 phoneme format | |
| phonemes = self.cmu_dict[word_lower][0] | |
| clean_phonemes = [re.sub(r"[0-9]", "", p) for p in phonemes] | |
| return self._convert_to_wav2vec_format(clean_phonemes) | |
| else: | |
| return self._estimate_phonemes(word) | |
| def _convert_to_wav2vec_format(self, cmu_phonemes: List[str]) -> List[str]: | |
| """Convert CMU phonemes to Wav2Vec2 format""" | |
| # Mapping from CMU to Wav2Vec2/eSpeak phonemes | |
| cmu_to_espeak = { | |
| "AA": "ɑ", | |
| "AE": "æ", | |
| "AH": "ʌ", | |
| "AO": "ɔ", | |
| "AW": "aʊ", | |
| "AY": "aɪ", | |
| "EH": "ɛ", | |
| "ER": "ɝ", | |
| "EY": "eɪ", | |
| "IH": "ɪ", | |
| "IY": "i", | |
| "OW": "oʊ", | |
| "OY": "ɔɪ", | |
| "UH": "ʊ", | |
| "UW": "u", | |
| "B": "b", | |
| "CH": "tʃ", | |
| "D": "d", | |
| "DH": "ð", | |
| "F": "f", | |
| "G": "ɡ", | |
| "HH": "h", | |
| "JH": "dʒ", | |
| "K": "k", | |
| "L": "l", | |
| "M": "m", | |
| "N": "n", | |
| "NG": "ŋ", | |
| "P": "p", | |
| "R": "r", | |
| "S": "s", | |
| "SH": "ʃ", | |
| "T": "t", | |
| "TH": "θ", | |
| "V": "v", | |
| "W": "w", | |
| "Y": "j", | |
| "Z": "z", | |
| "ZH": "ʒ", | |
| } | |
| converted = [] | |
| for phoneme in cmu_phonemes: | |
| converted_phoneme = cmu_to_espeak.get(phoneme, phoneme.lower()) | |
| converted.append(converted_phoneme) | |
| return converted | |
| def _get_ipa(self, word: str) -> str: | |
| """Get IPA transcription""" | |
| try: | |
| return ipa.convert(word) | |
| except: | |
| return f"/{word}/" | |
| def _estimate_phonemes(self, word: str) -> List[str]: | |
| """Estimate phonemes for unknown words""" | |
| # Basic phoneme estimation with eSpeak-style output | |
| phoneme_map = { | |
| "ch": ["tʃ"], | |
| "sh": ["ʃ"], | |
| "th": ["θ"], | |
| "ph": ["f"], | |
| "ck": ["k"], | |
| "ng": ["ŋ"], | |
| "qu": ["k", "w"], | |
| "a": ["æ"], | |
| "e": ["ɛ"], | |
| "i": ["ɪ"], | |
| "o": ["ʌ"], | |
| "u": ["ʌ"], | |
| "b": ["b"], | |
| "c": ["k"], | |
| "d": ["d"], | |
| "f": ["f"], | |
| "g": ["ɡ"], | |
| "h": ["h"], | |
| "j": ["dʒ"], | |
| "k": ["k"], | |
| "l": ["l"], | |
| "m": ["m"], | |
| "n": ["n"], | |
| "p": ["p"], | |
| "r": ["r"], | |
| "s": ["s"], | |
| "t": ["t"], | |
| "v": ["v"], | |
| "w": ["w"], | |
| "x": ["k", "s"], | |
| "y": ["j"], | |
| "z": ["z"], | |
| } | |
| word = word.lower() | |
| phonemes = [] | |
| i = 0 | |
| while i < len(word): | |
| # Check 2-letter combinations first | |
| if i <= len(word) - 2: | |
| two_char = word[i : i + 2] | |
| if two_char in phoneme_map: | |
| phonemes.extend(phoneme_map[two_char]) | |
| i += 2 | |
| continue | |
| # Single character | |
| char = word[i] | |
| if char in phoneme_map: | |
| phonemes.extend(phoneme_map[char]) | |
| i += 1 | |
| return phonemes | |
| class PhonemeComparator: | |
| """Compare reference and learner phoneme sequences""" | |
| def __init__(self): | |
| # Vietnamese speakers' common phoneme substitutions | |
| self.substitution_patterns = { | |
| "θ": ["f", "s", "t"], # TH → F, S, T | |
| "ð": ["d", "z", "v"], # DH → D, Z, V | |
| "v": ["w", "f"], # V → W, F | |
| "r": ["l"], # R → L | |
| "l": ["r"], # L → R | |
| "z": ["s"], # Z → S | |
| "ʒ": ["ʃ", "z"], # ZH → SH, Z | |
| "ŋ": ["n"], # NG → N | |
| } | |
| # Difficulty levels for Vietnamese speakers | |
| self.difficulty_map = { | |
| "θ": 0.9, # th (think) | |
| "ð": 0.9, # th (this) | |
| "v": 0.8, # v | |
| "z": 0.8, # z | |
| "ʒ": 0.9, # zh (measure) | |
| "r": 0.7, # r | |
| "l": 0.6, # l | |
| "w": 0.5, # w | |
| "f": 0.4, # f | |
| "s": 0.3, # s | |
| "ʃ": 0.5, # sh | |
| "tʃ": 0.4, # ch | |
| "dʒ": 0.5, # j | |
| "ŋ": 0.3, # ng | |
| } | |
| def compare_phoneme_sequences( | |
| self, reference_phonemes: str, learner_phonemes: str | |
| ) -> List[Dict]: | |
| """Compare reference and learner phoneme sequences""" | |
| # Split phoneme strings | |
| ref_phones = reference_phonemes.split() | |
| learner_phones = learner_phonemes.split() | |
| print(f"Reference phonemes: {ref_phones}") | |
| print(f"Learner phonemes: {learner_phones}") | |
| # Simple alignment comparison | |
| comparisons = [] | |
| max_len = max(len(ref_phones), len(learner_phones)) | |
| for i in range(max_len): | |
| ref_phoneme = ref_phones[i] if i < len(ref_phones) else "" | |
| learner_phoneme = learner_phones[i] if i < len(learner_phones) else "" | |
| if ref_phoneme and learner_phoneme: | |
| # Both present - check accuracy | |
| if ref_phoneme == learner_phoneme: | |
| status = "correct" | |
| score = 1.0 | |
| elif self._is_acceptable_substitution(ref_phoneme, learner_phoneme): | |
| status = "acceptable" | |
| score = 0.7 | |
| else: | |
| status = "wrong" | |
| score = 0.2 | |
| elif ref_phoneme and not learner_phoneme: | |
| # Missing phoneme | |
| status = "missing" | |
| score = 0.0 | |
| elif learner_phoneme and not ref_phoneme: | |
| # Extra phoneme | |
| status = "extra" | |
| score = 0.0 | |
| else: | |
| continue | |
| comparison = { | |
| "position": i, | |
| "reference_phoneme": ref_phoneme, | |
| "learner_phoneme": learner_phoneme, | |
| "status": status, | |
| "score": score, | |
| "difficulty": self.difficulty_map.get(ref_phoneme, 0.3), | |
| } | |
| comparisons.append(comparison) | |
| return comparisons | |
| def _is_acceptable_substitution(self, reference: str, learner: str) -> bool: | |
| """Check if learner phoneme is acceptable substitution for Vietnamese speakers""" | |
| acceptable = self.substitution_patterns.get(reference, []) | |
| return learner in acceptable | |
| # ============================================================================= | |
| # WORD ANALYZER | |
| # ============================================================================= | |
| class WordAnalyzer: | |
| """Analyze word-level pronunciation accuracy using character-based ASR""" | |
| def __init__(self): | |
| self.g2p = SimpleG2P() | |
| self.comparator = PhonemeComparator() | |
| def analyze_words(self, reference_text: str, learner_phonemes: str) -> Dict: | |
| """Analyze word-level pronunciation using phoneme representation from character ASR""" | |
| # Get reference phonemes by word | |
| reference_words = self.g2p.text_to_phonemes(reference_text) | |
| # Get overall phoneme comparison | |
| reference_phoneme_string = self.g2p.get_reference_phoneme_string(reference_text) | |
| phoneme_comparisons = self.comparator.compare_phoneme_sequences( | |
| reference_phoneme_string, learner_phonemes | |
| ) | |
| # Map phonemes back to words | |
| word_highlights = self._create_word_highlights( | |
| reference_words, phoneme_comparisons | |
| ) | |
| # Identify wrong words | |
| wrong_words = self._identify_wrong_words(word_highlights, phoneme_comparisons) | |
| return { | |
| "word_highlights": word_highlights, | |
| "phoneme_differences": phoneme_comparisons, | |
| "wrong_words": wrong_words, | |
| } | |
| def _create_word_highlights( | |
| self, reference_words: List[Dict], phoneme_comparisons: List[Dict] | |
| ) -> List[Dict]: | |
| """Create word highlighting data""" | |
| word_highlights = [] | |
| phoneme_index = 0 | |
| for word_data in reference_words: | |
| word = word_data["word"] | |
| word_phonemes = word_data["phonemes"] | |
| num_phonemes = len(word_phonemes) | |
| # Get phoneme scores for this word | |
| word_phoneme_scores = [] | |
| for j in range(num_phonemes): | |
| if phoneme_index + j < len(phoneme_comparisons): | |
| comparison = phoneme_comparisons[phoneme_index + j] | |
| word_phoneme_scores.append(comparison["score"]) | |
| # Calculate word score | |
| word_score = np.mean(word_phoneme_scores) if word_phoneme_scores else 0.0 | |
| # Create word highlight | |
| highlight = { | |
| "word": word, | |
| "score": float(word_score), | |
| "status": self._get_word_status(word_score), | |
| "color": self._get_word_color(word_score), | |
| "phonemes": word_phonemes, | |
| "ipa": word_data["ipa"], | |
| "phoneme_scores": word_phoneme_scores, | |
| "phoneme_start_index": phoneme_index, | |
| "phoneme_end_index": phoneme_index + num_phonemes - 1, | |
| } | |
| word_highlights.append(highlight) | |
| phoneme_index += num_phonemes | |
| return word_highlights | |
| def _identify_wrong_words( | |
| self, word_highlights: List[Dict], phoneme_comparisons: List[Dict] | |
| ) -> List[Dict]: | |
| """Identify words that were pronounced incorrectly""" | |
| wrong_words = [] | |
| for word_highlight in word_highlights: | |
| if word_highlight["score"] < 0.6: # Threshold for wrong pronunciation | |
| # Find specific phoneme errors for this word | |
| start_idx = word_highlight["phoneme_start_index"] | |
| end_idx = word_highlight["phoneme_end_index"] | |
| wrong_phonemes = [] | |
| missing_phonemes = [] | |
| for i in range(start_idx, min(end_idx + 1, len(phoneme_comparisons))): | |
| comparison = phoneme_comparisons[i] | |
| if comparison["status"] == "wrong": | |
| wrong_phonemes.append( | |
| { | |
| "expected": comparison["reference_phoneme"], | |
| "actual": comparison["learner_phoneme"], | |
| "difficulty": comparison["difficulty"], | |
| } | |
| ) | |
| elif comparison["status"] == "missing": | |
| missing_phonemes.append( | |
| { | |
| "phoneme": comparison["reference_phoneme"], | |
| "difficulty": comparison["difficulty"], | |
| } | |
| ) | |
| wrong_word = { | |
| "word": word_highlight["word"], | |
| "score": word_highlight["score"], | |
| "expected_phonemes": word_highlight["phonemes"], | |
| "ipa": word_highlight["ipa"], | |
| "wrong_phonemes": wrong_phonemes, | |
| "missing_phonemes": missing_phonemes, | |
| "tips": self._get_vietnamese_tips(wrong_phonemes, missing_phonemes), | |
| } | |
| wrong_words.append(wrong_word) | |
| return wrong_words | |
| def _get_word_status(self, score: float) -> str: | |
| """Get word status from score""" | |
| if score >= 0.8: | |
| return "excellent" | |
| elif score >= 0.6: | |
| return "good" | |
| elif score >= 0.4: | |
| return "needs_practice" | |
| else: | |
| return "poor" | |
| def _get_word_color(self, score: float) -> str: | |
| """Get color for word highlighting""" | |
| if score >= 0.8: | |
| return "#22c55e" # Green | |
| elif score >= 0.6: | |
| return "#84cc16" # Light green | |
| elif score >= 0.4: | |
| return "#eab308" # Yellow | |
| else: | |
| return "#ef4444" # Red | |
| def _get_vietnamese_tips( | |
| self, wrong_phonemes: List[Dict], missing_phonemes: List[Dict] | |
| ) -> List[str]: | |
| """Get Vietnamese-specific pronunciation tips""" | |
| tips = [] | |
| # Tips for specific Vietnamese pronunciation challenges | |
| vietnamese_tips = { | |
| "θ": "Đặt lưỡi giữa răng trên và dưới, thổi nhẹ (think, three)", | |
| "ð": "Giống θ nhưng rung dây thanh âm (this, that)", | |
| "v": "Chạm môi dưới vào răng trên, không dùng cả hai môi như tiếng Việt", | |
| "r": "Cuộn lưỡi nhưng không chạm vào vòm miệng, không lăn lưỡi", | |
| "l": "Đầu lưỡi chạm vào vòm miệng sau răng", | |
| "z": "Giống âm 's' nhưng có rung dây thanh âm", | |
| "ʒ": "Giống âm 'ʃ' (sh) nhưng có rung dây thanh âm", | |
| "w": "Tròn môi như âm 'u', không dùng răng như âm 'v'", | |
| } | |
| # Add tips for wrong phonemes | |
| for wrong in wrong_phonemes: | |
| expected = wrong["expected"] | |
| actual = wrong["actual"] | |
| if expected in vietnamese_tips: | |
| tips.append(f"Âm '{expected}': {vietnamese_tips[expected]}") | |
| else: | |
| tips.append(f"Luyện âm '{expected}' thay vì '{actual}'") | |
| # Add tips for missing phonemes | |
| for missing in missing_phonemes: | |
| phoneme = missing["phoneme"] | |
| if phoneme in vietnamese_tips: | |
| tips.append(f"Thiếu âm '{phoneme}': {vietnamese_tips[phoneme]}") | |
| return tips | |
| class SimpleFeedbackGenerator: | |
| """Generate simple, actionable feedback in Vietnamese""" | |
| def generate_feedback( | |
| self, | |
| overall_score: float, | |
| wrong_words: List[Dict], | |
| phoneme_comparisons: List[Dict], | |
| ) -> List[str]: | |
| """Generate focused Vietnamese feedback with actionable improvements""" | |
| feedback = [] | |
| # More specific and actionable feedback based on score ranges | |
| if overall_score >= 0.8: | |
| feedback.append(f"Xuất sắc! Điểm: {int(overall_score * 100)}%. Tiếp tục duy trì và luyện tập thêm tốc độ tự nhiên.") | |
| elif overall_score >= 0.7: | |
| feedback.append(f"Tốt! Điểm: {int(overall_score * 100)}%. Để đạt 80%+, hãy tập trung vào nhịp điệu và ngữ điệu.") | |
| elif overall_score >= 0.6: | |
| feedback.append(f"Khá! Điểm: {int(overall_score * 100)}%. Để cải thiện, hãy phát âm chậm hơn và rõ ràng từng âm.") | |
| elif overall_score >= 0.4: | |
| feedback.append(f"Cần cải thiện. Điểm: {int(overall_score * 100)}%. Nghe lại mẫu và tập từng từ riêng lẻ trước.") | |
| else: | |
| feedback.append(f"Điểm: {int(overall_score * 100)}%. Hãy nghe mẫu 3-5 lần, sau đó tập phát âm từng từ chậm rãi.") | |
| # More specific wrong words feedback with improvement path | |
| if wrong_words: | |
| # Sort by score to focus on worst words first | |
| sorted_words = sorted(wrong_words, key=lambda x: x["score"]) | |
| if len(wrong_words) == 1: | |
| word = sorted_words[0] | |
| feedback.append(f"Tập trung vào từ '{word['word']}' (điểm: {int(word['score']*100)}%). Click vào từ để nghe lại.") | |
| elif len(wrong_words) <= 3: | |
| worst_word = sorted_words[0] | |
| feedback.append(f"Ưu tiên cải thiện: '{worst_word['word']}' ({int(worst_word['score']*100)}%) - các từ khác sẽ dễ hơn sau khi nắm được từ này.") | |
| else: | |
| # Focus on pattern recognition | |
| feedback.append(f"Có {len(wrong_words)} từ cần cải thiện. Bắt đầu với 2 từ khó nhất và luyện tập 5 lần mỗi từ.") | |
| # Specific phoneme guidance with improvement strategy | |
| problem_phonemes = defaultdict(int) | |
| for comparison in phoneme_comparisons: | |
| if comparison["status"] in ["wrong", "missing"]: | |
| phoneme = comparison["reference_phoneme"] | |
| problem_phonemes[phoneme] += 1 | |
| if problem_phonemes: | |
| most_difficult = sorted( | |
| problem_phonemes.items(), key=lambda x: x[1], reverse=True | |
| ) | |
| top_problems = most_difficult[:2] # Focus on top 2 problems | |
| detailed_phoneme_tips = { | |
| "θ": "Đặt đầu lưỡi giữa 2 hàm răng, thổi nhẹ ra. Luyện: 'think', 'three', 'thank'.", | |
| "ð": "Như /θ/ nhưng rung dây thanh. Luyện: 'this', 'that', 'the'.", | |
| "v": "Răng trên chạm nhẹ môi dưới (không phải 2 môi). Luyện: 'very', 'have', 'love'.", | |
| "r": "Cuộn lưỡi lên nhưng KHÔNG chạm nóc miệng. Luyện: 'red', 'run', 'car'.", | |
| "l": "Đầu lưỡi chạm nướu răng trên. Luyện: 'love', 'like', 'tell'.", | |
| "z": "Như 's' nhưng rung dây thanh (đặt tay vào cổ để cảm nhận). Luyện: 'zoo', 'buzz'.", | |
| "ɛ": "Mở miệng vừa, lưỡi thấp (như 'e' trong 'ten'). Luyện: 'bed', 'red', 'get'.", | |
| "æ": "Mở miệng rộng, hàm dưới hạ thấp. Luyện: 'cat', 'man', 'bad'.", | |
| "ɪ": "Âm 'i' ngắn, lưỡi thả lỏng. Luyện: 'sit', 'big', 'this'.", | |
| "ʊ": "Âm 'u' ngắn, môi tròn nhẹ. Luyện: 'book', 'put', 'could'.", | |
| } | |
| # Provide specific guidance for the most problematic phoneme | |
| for phoneme, count in top_problems[:1]: # Focus on the worst one | |
| if phoneme in detailed_phoneme_tips: | |
| improvement = 100 - int((count / len(phoneme_comparisons)) * 100) | |
| feedback.append( | |
| f"🎯 Tập trung âm /{phoneme}/: {detailed_phoneme_tips[phoneme]} Cải thiện âm này sẽ tăng điểm ~{improvement}%." | |
| ) | |
| # Add specific action steps based on score range | |
| if overall_score < 0.8: | |
| if overall_score < 0.5: | |
| feedback.append("📚 Bước tiếp: 1) Nghe mẫu 5 lần, 2) Tập phát âm từng từ 3 lần, 3) Ghi âm lại và so sánh.") | |
| elif overall_score < 0.7: | |
| feedback.append("📚 Bước tiếp: 1) Tập từ khó nhất 5 lần, 2) Đọc cả câu chậm 2 lần, 3) Tăng tốc độ dần.") | |
| else: | |
| feedback.append("📚 Bước tiếp: 1) Luyện ngữ điệu tự nhiên, 2) Kết nối âm giữa các từ, 3) Tập nói với cảm xúc.") | |
| return feedback | |
| def convert_numpy_types(obj): | |
| """Convert numpy types to Python native types""" | |
| if isinstance(obj, np.integer): | |
| return int(obj) | |
| elif isinstance(obj, np.floating): | |
| return float(obj) | |
| elif isinstance(obj, np.ndarray): | |
| return obj.tolist() | |
| elif isinstance(obj, dict): | |
| return {key: convert_numpy_types(value) for key, value in obj.items()} | |
| elif isinstance(obj, list): | |
| return [convert_numpy_types(item) for item in obj] | |
| else: | |
| return obj | |