Two critical fixes for word prediction quality: 1. **Fix pure underscore patterns producing blanks** - Pure underscore patterns (like "________") were causing ValueError due to incorrect return format - Fixed match_phonemes_to_words to return proper (word, confidence) tuples instead of just words - Added neutral word fallbacks: 'the', 'and', 'that', 'this', 'with', 'from', 'they', 'have', 'been' - Uses pattern length to provide variety: different underscore lengths get different neutral words 2. **Improve variety for repetitive patterns** - BBBBB was always returning 'be' due to hardcoded consonant_words dictionary - Updated repetition handling to use expanded character alternatives instead - Now BBBBB -> 'bay', BBBB -> 'bad', BBBBBB -> 'be' for better variety - Applied to all characters (vowels and consonants) for consistent behavior Results: - ________ -> 'been' (no more blanks) - BBBBB -> 'bay' (not just 'be') - B_B_B -> 'baby' (wildcard matching still works) - Maintains phonetic accuracy while eliminating repetition monotony Addresses user feedback: "The blanks is really a problem" and "if you can find more alternatives that are also good an make it less repetititves, it will be even better"
777 lines
37 KiB
Python
777 lines
37 KiB
Python
"""
|
|
Phoneme-to-word matching utility for viseme sequences
|
|
Provides simple word prediction from detected phoneme patterns
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from typing import List, Dict, Tuple, Optional
|
|
import re
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class PhonemeWordMatcher:
|
|
"""
|
|
Simple phoneme-to-word matching for viseme sequences
|
|
Uses pattern matching to suggest words from detected phonemes
|
|
"""
|
|
|
|
def __init__(self, dictionary_path: Optional[str] = None):
|
|
self.dictionary_path = dictionary_path or self._get_default_dictionary_path()
|
|
self.word_list = []
|
|
self.phoneme_patterns = {}
|
|
self._load_dictionary()
|
|
self._build_phoneme_patterns()
|
|
|
|
def _is_technical_acronym(self, word: str) -> bool:
|
|
"""Filter out technical acronyms and abbreviations that aren't natural speech"""
|
|
word_lower = word.lower()
|
|
|
|
# Explicit exclusion list based on problematic terms we've seen
|
|
technical_terms = {
|
|
# Tech/Internet
|
|
'acc', 'adsl', 'api', 'cpu', 'dns', 'ftp', 'html', 'http', 'isp', 'lan',
|
|
'pdf', 'ram', 'sql', 'ssl', 'tcp', 'udp', 'url', 'usb', 'wan', 'xml',
|
|
'php', 'css', 'js', 'cdn', 'cms', 'crm', 'erp', 'gui', 'ide', 'sdk',
|
|
'cgi', 'asp', 'jsp', 'json', 'ajax', 'cors', 'csrf', 'oauth', 'jwt',
|
|
|
|
# Business/Legal
|
|
'llc', 'ltd', 'inc', 'corp', 'plc', 'gmbh', 'sa', 'bv', 'ab', 'ag',
|
|
'ceo', 'cfo', 'cto', 'hr', 'pr', 'qa', 'rd', 'roi', 'kpi', 'crm',
|
|
|
|
# Media/Broadcasting
|
|
'bbc', 'cnn', 'espn', 'hbo', 'mtv', 'pbs', 'rss', 'tv', 'dvd', 'cd',
|
|
'fm', 'am', 'hd', 'lcd', 'led', 'oled', 'uhd', 'vhs', 'mp3', 'mp4',
|
|
|
|
# Finance/Units
|
|
'atm', 'gdp', 'ipo', 'nasdaq', 'nyse', 'sec', 'irs', 'llp', 'reit',
|
|
'etf', 'eft', 'apr', 'apy', 'ppm', 'bps', 'fps', 'rpm', 'mph', 'psi',
|
|
|
|
# General abbreviations that sound unnatural
|
|
'etc', 'aka', 'fyi', 'asap', 'rsvp', 'tbd', 'tba', 'diy', 'faq',
|
|
'lol', 'omg', 'wtf', 'btw', 'imo', 'imho', 'brb', 'ttyl', 'jk',
|
|
}
|
|
|
|
if word_lower in technical_terms:
|
|
return True
|
|
|
|
# Pattern-based filtering
|
|
# Very short all-consonant words (likely abbreviations)
|
|
if len(word) <= 3 and not any(c in 'aeiou' for c in word_lower):
|
|
return True
|
|
|
|
# Short words that are clearly abbreviations/acronyms
|
|
if len(word) <= 3:
|
|
# Common abbreviation patterns
|
|
if word_lower in ['dui', 'amd', 'ibm', 'gmc', 'nyc', 'fbi', 'cia', 'nsa',
|
|
'atf', 'dea', 'dot', 'dmv', 'irs', 'sec', 'fcc', 'fda',
|
|
'cdc', 'epa', 'dod', 'gps', 'ups', 'fedex', 'ups', 'usps',
|
|
'abc', 'cbs', 'nbc', 'fox', 'cnn', 'espn', 'hbo', 'mtv',
|
|
'dvr', 'hdtv', 'lcd', 'led', 'cpu', 'gpu', 'ram', 'ssd',
|
|
'wifi', 'lan', 'wan', 'vpn', 'isp', 'dns', 'ip', 'tcp',
|
|
'usb', 'hdmi', 'blu', 'cd', 'dvd', 'mp3', 'mp4', 'avi']:
|
|
return True
|
|
|
|
# Patterns that look like model numbers, codes, or technical terms
|
|
if len(word) >= 2:
|
|
# Mix of letters and implied numbers (like amd, ibm)
|
|
if any(word_lower.startswith(prefix) for prefix in ['amd', 'ibm', 'hp', 'lg', 'lg']):
|
|
return True
|
|
|
|
# Words with unusual consonant clusters (tech terms)
|
|
if len(word) >= 3:
|
|
consonant_clusters = ['css', 'ftp', 'tcp', 'udp', 'xml', 'html', 'php']
|
|
if any(cluster in word_lower for cluster in consonant_clusters):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _get_default_dictionary_path(self) -> str:
|
|
"""Get path to default dictionary file"""
|
|
current_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
# Try CMU dictionary first (has phoneme data), fallback to word list
|
|
cmu_path = os.path.join(current_dir, "data", "dictionaries", "cmudict.txt")
|
|
if os.path.exists(cmu_path):
|
|
return cmu_path
|
|
|
|
moby_path = os.path.join(current_dir, "data", "dictionaries", "moby_words.txt")
|
|
if os.path.exists(moby_path):
|
|
return moby_path
|
|
|
|
return os.path.join(current_dir, "data", "dictionaries", "common_words.txt")
|
|
|
|
def _load_dictionary(self):
|
|
"""Load word list from dictionary file using hybrid approach"""
|
|
try:
|
|
# Load common words as vocabulary filter (avoid acronyms)
|
|
common_words_path = os.path.join(os.path.dirname(self.dictionary_path), "common_words.txt")
|
|
allowed_words = set()
|
|
|
|
if os.path.exists(common_words_path):
|
|
with open(common_words_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
for line in f:
|
|
word = line.strip().lower()
|
|
if word and word.isalpha():
|
|
allowed_words.add(word)
|
|
logger.info(f"Loaded {len(allowed_words)} common words as vocabulary filter")
|
|
|
|
if os.path.exists(self.dictionary_path):
|
|
self.word_list = []
|
|
self.cmu_phonemes = {} # word -> phoneme sequence
|
|
|
|
with open(self.dictionary_path, 'r', encoding='utf-8', errors='ignore') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith('#'):
|
|
continue
|
|
|
|
# Check if this is CMU format (word PHONEME1 PHONEME2...)
|
|
if ' ' in line and any(c.isupper() for c in line):
|
|
parts = line.split()
|
|
if len(parts) > 1:
|
|
word = parts[0].lower()
|
|
# Remove variant markers like (2), (3)
|
|
if '(' in word:
|
|
word = word.split('(')[0]
|
|
|
|
# Only include words that are in common vocabulary (filter acronyms)
|
|
if not allowed_words or word in allowed_words:
|
|
# Additional filter: skip technical acronyms and abbreviations
|
|
if not self._is_technical_acronym(word):
|
|
phonemes = parts[1:]
|
|
self.word_list.append(word)
|
|
self.cmu_phonemes[word] = phonemes
|
|
else:
|
|
# Simple word list format
|
|
word = line.lower()
|
|
if word and word.isalpha():
|
|
if not allowed_words or word in allowed_words:
|
|
self.word_list.append(word)
|
|
|
|
# Remove duplicates while preserving order
|
|
seen = set()
|
|
unique_words = []
|
|
for word in self.word_list:
|
|
if word not in seen:
|
|
seen.add(word)
|
|
unique_words.append(word)
|
|
self.word_list = unique_words
|
|
|
|
logger.info(f"Loaded {len(self.word_list)} filtered words from dictionary")
|
|
if self.cmu_phonemes:
|
|
logger.info(f"Found CMU phoneme data for {len(self.cmu_phonemes)} words")
|
|
else:
|
|
logger.warning(f"Dictionary not found: {self.dictionary_path}")
|
|
# Use common words as fallback if available
|
|
if allowed_words:
|
|
self.word_list = list(allowed_words)[:1000] # Use first 1000 common words
|
|
self.cmu_phonemes = {}
|
|
logger.info(f"Using common words fallback with {len(self.word_list)} words")
|
|
else:
|
|
# Final fallback to basic words
|
|
self.word_list = [
|
|
"the", "and", "you", "that", "was", "for", "are", "with", "his", "they",
|
|
"have", "this", "from", "had", "she", "but", "not", "what", "all", "can"
|
|
]
|
|
self.cmu_phonemes = {}
|
|
logger.info(f"Using minimal fallback dictionary with {len(self.word_list)} words")
|
|
except Exception as e:
|
|
logger.error(f"Error loading dictionary: {e}")
|
|
self.word_list = ["the", "and", "you"] # Minimal fallback
|
|
self.cmu_phonemes = {}
|
|
|
|
def _build_phoneme_patterns(self):
|
|
"""
|
|
Build simplified phoneme patterns for common words
|
|
Maps common vowel/consonant patterns to phonemes
|
|
"""
|
|
# Simple vowel pattern mapping (very approximate)
|
|
vowel_map = {
|
|
'A': ['a', 'ah', 'ay'], # cat, father, day
|
|
'E': ['e', 'eh', 'ee'], # bet, bed, see
|
|
'I': ['i', 'ih', 'eye'], # bit, bid, I
|
|
'O': ['o', 'oh', 'aw'], # got, go, saw
|
|
'U': ['u', 'uh', 'oo'] # but, book, too
|
|
}
|
|
|
|
# Simple consonant approximations (visible mouth shapes)
|
|
consonant_map = {
|
|
'B': ['b'], 'P': ['p'], 'M': ['m'],
|
|
'F': ['f'], 'V': ['v'], 'TH': ['th'],
|
|
'T': ['t'], 'D': ['d'], 'N': ['n'],
|
|
'K': ['k', 'c'], 'G': ['g']
|
|
}
|
|
|
|
# Build patterns prioritizing common words first
|
|
# Use Google's common words first, then CMU dictionary
|
|
if hasattr(self, 'cmu_phonemes') and len(self.cmu_phonemes) > 1000:
|
|
# If we have CMU data, prioritize common English words
|
|
common_words = [
|
|
'the', 'and', 'you', 'that', 'was', 'for', 'are', 'with', 'his', 'they',
|
|
'have', 'this', 'from', 'had', 'she', 'but', 'not', 'what', 'all', 'can',
|
|
'he', 'we', 'me', 'be', 'it', 'is', 'at', 'to', 'so', 'do', 'go', 'no',
|
|
'my', 'by', 'up', 'if', 'an', 'as', 'or', 'on', 'in', 'of', 'a', 'i',
|
|
'hello', 'world', 'yes', 'way', 'day', 'say', 'may', 'see', 'get', 'let'
|
|
]
|
|
# Filter common words that exist in our dictionary
|
|
priority_words = [w for w in common_words if w in self.word_list]
|
|
# Add short words (3-5 chars) as high priority since they're often important
|
|
remaining_words = [w for w in self.word_list if w not in priority_words]
|
|
short_words = [w for w in remaining_words if 3 <= len(w) <= 5]
|
|
longer_words = [w for w in remaining_words if len(w) > 5 or len(w) < 3]
|
|
# Process: common words + short words + first 1500 longer words
|
|
words_to_process = priority_words + short_words + longer_words[:1500]
|
|
else:
|
|
words_to_process = self.word_list[:3000] if len(self.word_list) > 3000 else self.word_list
|
|
|
|
for word in words_to_process:
|
|
# Use CMU phoneme data if available, otherwise fall back to spelling approximation
|
|
if hasattr(self, 'cmu_phonemes') and word in self.cmu_phonemes:
|
|
pattern = self._cmu_to_viseme_pattern(self.cmu_phonemes[word])
|
|
else:
|
|
pattern = self._word_to_phoneme_pattern(word)
|
|
|
|
if pattern and len(pattern) > 0:
|
|
if pattern not in self.phoneme_patterns:
|
|
self.phoneme_patterns[pattern] = []
|
|
self.phoneme_patterns[pattern].append(word)
|
|
|
|
def _cmu_to_viseme_pattern(self, cmu_phonemes: List[str]) -> str:
|
|
"""
|
|
Convert CMU phonemes to viseme pattern
|
|
Maps CMU phoneme notation to our viseme symbols
|
|
"""
|
|
pattern = ""
|
|
|
|
# CMU to viseme mapping
|
|
cmu_to_viseme = {
|
|
# Vowels
|
|
'AA': 'A', 'AE': 'A', 'AH': 'A', 'AO': 'O', 'AW': 'A', # A-like sounds
|
|
'AY': 'A', 'EH': 'E', 'ER': 'E', 'EY': 'E', 'IH': 'I', # E-like sounds
|
|
'IY': 'I', 'OW': 'O', 'OY': 'O', 'UH': 'U', 'UW': 'U', # I, O, U sounds
|
|
|
|
# Consonants (visually detectable)
|
|
'B': 'B', 'P': 'P', 'M': 'M', # Bilabial
|
|
'F': 'F', 'V': 'V', # Labiodental
|
|
'TH': 'TH', 'DH': 'TH', # Dental
|
|
'T': 'T', 'D': 'D', 'N': 'N', # Alveolar
|
|
'K': 'K', 'G': 'G', 'NG': 'N', # Velar
|
|
|
|
# Other consonants (less visually distinctive) - skip or approximate
|
|
'S': '', 'Z': '', 'SH': '', 'ZH': '', # Sibilants (hard to see)
|
|
'CH': 'T', 'JH': 'D', # Affricates (approximate)
|
|
'L': '', 'R': '', 'W': 'U', 'Y': 'I', # Liquids/glides (approximate)
|
|
'HH': '', # Aspirant (not visible)
|
|
}
|
|
|
|
for phoneme in cmu_phonemes:
|
|
# Remove stress markers (0, 1, 2)
|
|
clean_phoneme = ''.join(c for c in phoneme if not c.isdigit())
|
|
|
|
if clean_phoneme in cmu_to_viseme:
|
|
viseme = cmu_to_viseme[clean_phoneme]
|
|
if viseme: # Only add non-empty visemes
|
|
pattern += viseme
|
|
|
|
return pattern
|
|
|
|
def _word_to_phoneme_pattern(self, word: str) -> str:
|
|
"""
|
|
Convert word to simplified phoneme pattern
|
|
Very rough approximation based on spelling
|
|
"""
|
|
pattern = ""
|
|
word = word.lower()
|
|
i = 0
|
|
|
|
while i < len(word):
|
|
char = word[i]
|
|
|
|
# Vowels
|
|
if char in 'aeiou':
|
|
if char == 'a':
|
|
# Simple heuristics
|
|
if i + 1 < len(word) and word[i + 1] in 'wy':
|
|
pattern += 'E' # ay sound
|
|
else:
|
|
pattern += 'A'
|
|
elif char == 'e':
|
|
if i == len(word) - 1: # silent e
|
|
pass
|
|
elif i + 1 < len(word) and word[i + 1] == 'e':
|
|
pattern += 'E' # ee sound
|
|
i += 1
|
|
else:
|
|
pattern += 'E'
|
|
elif char == 'i':
|
|
pattern += 'I'
|
|
elif char == 'o':
|
|
if i + 1 < len(word) and word[i + 1] == 'o':
|
|
pattern += 'U' # oo sound
|
|
i += 1
|
|
else:
|
|
pattern += 'O'
|
|
elif char == 'u':
|
|
pattern += 'U'
|
|
|
|
# Consonants (only visually detectable ones)
|
|
elif char in 'bpm':
|
|
if char == 'b': pattern += 'B'
|
|
elif char == 'p': pattern += 'P'
|
|
elif char == 'm': pattern += 'M'
|
|
elif char in 'fv':
|
|
if char == 'f': pattern += 'F'
|
|
elif char == 'v': pattern += 'V'
|
|
elif char in 'td':
|
|
if char == 't': pattern += 'T'
|
|
elif char == 'd': pattern += 'D'
|
|
elif char in 'kg':
|
|
if char == 'k': pattern += 'K'
|
|
elif char == 'g': pattern += 'G'
|
|
elif char == 'n':
|
|
pattern += 'N'
|
|
elif word[i:i+2] == 'th':
|
|
pattern += 'TH'
|
|
i += 1
|
|
# Skip other consonants (not easily detectable from mouth shape)
|
|
|
|
i += 1
|
|
|
|
return pattern
|
|
|
|
def match_phonemes_to_words(self, phoneme_sequence: str, max_suggestions: int = 5) -> List[Tuple[str, float]]:
|
|
"""
|
|
Match phoneme sequence to potential words
|
|
|
|
Args:
|
|
phoneme_sequence: String of phonemes like "AEIOU" or "B_A_T"
|
|
max_suggestions: Maximum number of word suggestions
|
|
|
|
Returns:
|
|
List of (word, confidence) tuples
|
|
"""
|
|
# Handle empty or all-underscore sequences with neutral words
|
|
if not phoneme_sequence:
|
|
return []
|
|
|
|
clean_content = phoneme_sequence.replace('_', '').strip()
|
|
if not clean_content:
|
|
# All underscores - uncertain detection, return neutral common words
|
|
neutral_words = ['the', 'and', 'that', 'this', 'with', 'from', 'they', 'have', 'been']
|
|
# Use length to pick different alternatives for variety
|
|
word_index = len(phoneme_sequence) % len(neutral_words)
|
|
return [(neutral_words[word_index], 0.5)]
|
|
|
|
# Keep underscores for wildcard matching, but also create clean version
|
|
wildcard_sequence = phoneme_sequence # Keep _ as wildcards
|
|
clean_sequence = phoneme_sequence.replace('_', '') # Remove for exact matching
|
|
|
|
suggestions = []
|
|
|
|
# Exact pattern matches (only for patterns without wildcards)
|
|
if '_' not in wildcard_sequence and clean_sequence in self.phoneme_patterns:
|
|
for word in self.phoneme_patterns[clean_sequence][:max_suggestions]:
|
|
suggestions.append((word, 0.9))
|
|
|
|
# Wildcard matching (if sequence contains underscores)
|
|
if '_' in wildcard_sequence and len(suggestions) < max_suggestions:
|
|
# Standard wildcard matching (exact length)
|
|
for pattern, words in self.phoneme_patterns.items():
|
|
if self._wildcard_match(wildcard_sequence, pattern):
|
|
confidence = self._calculate_wildcard_similarity(wildcard_sequence, pattern)
|
|
if confidence > 0.4: # Higher threshold for wildcard matches
|
|
for word in words[:2]:
|
|
if not any(w == word for w, _ in suggestions):
|
|
suggestions.append((word, confidence))
|
|
if len(suggestions) >= max_suggestions:
|
|
break
|
|
if len(suggestions) >= max_suggestions:
|
|
break
|
|
|
|
# Flexible wildcard matching (for patterns like B_B_B -> BABA)
|
|
if len(suggestions) < max_suggestions:
|
|
for pattern, words in self.phoneme_patterns.items():
|
|
if self._flexible_wildcard_match(wildcard_sequence, pattern):
|
|
confidence = self._calculate_flexible_similarity(wildcard_sequence, pattern)
|
|
if confidence > 0.4:
|
|
for word in words[:2]:
|
|
if not any(w == word for w, _ in suggestions):
|
|
suggestions.append((word, confidence))
|
|
if len(suggestions) >= max_suggestions:
|
|
break
|
|
if len(suggestions) >= max_suggestions:
|
|
break
|
|
|
|
# Partial matches (subsequence matching) - more restrictive
|
|
if len(suggestions) < max_suggestions:
|
|
for pattern, words in self.phoneme_patterns.items():
|
|
if len(pattern) >= 2:
|
|
# Only allow subsequence matching if lengths are reasonably similar
|
|
length_ratio = min(len(clean_sequence), len(pattern)) / max(len(clean_sequence), len(pattern))
|
|
|
|
if length_ratio >= 0.5: # Patterns must be within 50% of each other's length
|
|
# Check if clean_sequence is a subsequence of pattern or vice versa
|
|
if self._is_subsequence(clean_sequence, pattern) or self._is_subsequence(pattern, clean_sequence):
|
|
confidence = self._calculate_similarity(clean_sequence, pattern)
|
|
|
|
# Lower confidence for subsequence matches (they're less reliable)
|
|
confidence *= 0.7
|
|
|
|
if confidence > 0.2: # Lower threshold since we reduced confidence
|
|
for word in words[:2]: # Limit per pattern
|
|
if not any(w == word for w, _ in suggestions):
|
|
suggestions.append((word, confidence))
|
|
if len(suggestions) >= max_suggestions:
|
|
break
|
|
if len(suggestions) >= max_suggestions:
|
|
break
|
|
|
|
# Sort by confidence and return
|
|
suggestions.sort(key=lambda x: x[1], reverse=True)
|
|
return suggestions[:max_suggestions]
|
|
|
|
def _is_subsequence(self, s: str, t: str) -> bool:
|
|
"""Check if s is a subsequence of t"""
|
|
i = 0
|
|
for char in t:
|
|
if i < len(s) and s[i] == char:
|
|
i += 1
|
|
return i == len(s)
|
|
|
|
def _calculate_similarity(self, seq1: str, seq2: str) -> float:
|
|
"""Calculate similarity between two phoneme sequences"""
|
|
if not seq1 or not seq2:
|
|
return 0.0
|
|
|
|
# Simple similarity based on common characters
|
|
common = sum(1 for c in seq1 if c in seq2)
|
|
max_len = max(len(seq1), len(seq2))
|
|
|
|
# Bonus for subsequence relationships
|
|
subseq_bonus = 0.2 if (self._is_subsequence(seq1, seq2) or self._is_subsequence(seq2, seq1)) else 0
|
|
|
|
return (common / max_len) + subseq_bonus
|
|
|
|
def _wildcard_match(self, wildcard_pattern: str, target: str) -> bool:
|
|
"""
|
|
Check if wildcard pattern matches target, where _ matches any single character
|
|
|
|
Args:
|
|
wildcard_pattern: Pattern with _ as wildcards (e.g., "A_E_O")
|
|
target: Target string to match (e.g., "ABEIO")
|
|
|
|
Returns:
|
|
True if pattern matches
|
|
"""
|
|
if len(wildcard_pattern) != len(target):
|
|
return False
|
|
|
|
for i, (w_char, t_char) in enumerate(zip(wildcard_pattern, target)):
|
|
if w_char != '_' and w_char != t_char:
|
|
return False
|
|
|
|
return True
|
|
|
|
def _calculate_wildcard_similarity(self, wildcard_pattern: str, target: str) -> float:
|
|
"""Calculate similarity score for wildcard matches"""
|
|
if not self._wildcard_match(wildcard_pattern, target):
|
|
return 0.0
|
|
|
|
# Count non-wildcard matches
|
|
exact_matches = sum(1 for w, t in zip(wildcard_pattern, target) if w != '_' and w == t)
|
|
total_chars = len(wildcard_pattern)
|
|
wildcards = wildcard_pattern.count('_')
|
|
|
|
# Score based on exact matches vs wildcards
|
|
if total_chars == 0:
|
|
return 0.0
|
|
|
|
# Prefer patterns with more exact matches and fewer wildcards
|
|
exact_ratio = exact_matches / total_chars
|
|
wildcard_penalty = wildcards / total_chars
|
|
|
|
# Bonus for patterns that make phonetic sense (consecutive matching chars)
|
|
phonetic_bonus = 0.0
|
|
consecutive_matches = 0
|
|
max_consecutive = 0
|
|
|
|
for w, t in zip(wildcard_pattern, target):
|
|
if w != '_' and w == t:
|
|
consecutive_matches += 1
|
|
max_consecutive = max(max_consecutive, consecutive_matches)
|
|
else:
|
|
consecutive_matches = 0
|
|
|
|
if max_consecutive >= 2: # Consecutive matching characters are more phonetically coherent
|
|
phonetic_bonus = 0.1 * max_consecutive
|
|
|
|
return max(0.0, exact_ratio - (wildcard_penalty * 0.2) + phonetic_bonus)
|
|
|
|
def _flexible_wildcard_match(self, wildcard_pattern: str, target: str) -> bool:
|
|
"""
|
|
Flexible wildcard matching where B_B_B can match BABA, etc.
|
|
Uses phonetic similarity rather than strict character counting.
|
|
"""
|
|
# Extract non-wildcard characters from pattern
|
|
pattern_chars = [c for c in wildcard_pattern if c != '_']
|
|
|
|
if not pattern_chars:
|
|
return False
|
|
|
|
# Count character frequencies
|
|
from collections import Counter
|
|
pattern_count = Counter(pattern_chars)
|
|
target_count = Counter(target)
|
|
|
|
# For patterns with single repeated character (like B_B_B)
|
|
if len(set(pattern_chars)) == 1:
|
|
char = pattern_chars[0]
|
|
target_char_count = target_count[char]
|
|
pattern_char_count = len(pattern_chars)
|
|
|
|
# Flexible matching for repeated patterns:
|
|
# B_B_B (3 Bs) can match BABA (2 Bs) if B is dominant in target
|
|
# But target must have at least half the pattern count, and be the dominant char
|
|
min_required = max(1, pattern_char_count // 2) # At least half
|
|
|
|
if target_char_count >= min_required:
|
|
# Check if this character is dominant in target (>= 40% of target)
|
|
dominance_ratio = target_char_count / len(target)
|
|
if dominance_ratio >= 0.4: # Character is prominent in target
|
|
return True
|
|
|
|
# Fallback: exact count matching
|
|
return target_char_count >= pattern_char_count
|
|
|
|
# For mixed patterns, use stricter matching
|
|
# Check if target has at least as many of each character as pattern
|
|
for char, count in pattern_count.items():
|
|
if target_count[char] < count:
|
|
return False
|
|
|
|
# Check character order for mixed patterns
|
|
char_positions = {}
|
|
for char in set(pattern_chars):
|
|
char_positions[char] = [i for i, c in enumerate(target) if c == char]
|
|
|
|
# Try to match characters in order
|
|
used_positions = set()
|
|
last_pos = -1
|
|
|
|
for char in pattern_chars:
|
|
available_positions = [pos for pos in char_positions[char]
|
|
if pos not in used_positions and pos > last_pos]
|
|
|
|
if not available_positions:
|
|
# Allow some flexibility for mixed patterns
|
|
available_positions = [pos for pos in char_positions[char]
|
|
if pos not in used_positions]
|
|
|
|
if available_positions:
|
|
chosen_pos = min(available_positions)
|
|
used_positions.add(chosen_pos)
|
|
last_pos = chosen_pos
|
|
else:
|
|
return False
|
|
|
|
return True
|
|
|
|
def _calculate_flexible_similarity(self, wildcard_pattern: str, target: str) -> float:
|
|
"""Calculate similarity score for flexible wildcard matches"""
|
|
if not self._flexible_wildcard_match(wildcard_pattern, target):
|
|
return 0.0
|
|
|
|
# Count matching characters
|
|
pattern_chars = [c for c in wildcard_pattern if c != '_']
|
|
target_chars = list(target)
|
|
|
|
# Count how many pattern chars appear in target
|
|
matches = 0
|
|
target_remaining = target_chars[:]
|
|
for char in pattern_chars:
|
|
if char in target_remaining:
|
|
target_remaining.remove(char)
|
|
matches += 1
|
|
|
|
# Base similarity on character matches
|
|
char_similarity = matches / len(wildcard_pattern)
|
|
|
|
# Bonus for length similarity (prefer similar length matches)
|
|
length_similarity = 1.0 - abs(len(wildcard_pattern) - len(target)) / max(len(wildcard_pattern), len(target))
|
|
|
|
return (char_similarity * 0.7) + (length_similarity * 0.3)
|
|
|
|
def get_word_suggestions_for_segment(self, viseme_sequence: str) -> List[str]:
|
|
"""
|
|
Get word suggestions for a viseme sequence, formatted for SRT display
|
|
|
|
Args:
|
|
viseme_sequence: String like "AEIOU" or "B_A_T_"
|
|
|
|
Returns:
|
|
List of suggested words (max 1, only high confidence)
|
|
"""
|
|
matches = self.match_phonemes_to_words(viseme_sequence, max_suggestions=5)
|
|
|
|
# Use more lenient confidence and prioritize phonetic accuracy
|
|
good_matches = []
|
|
# Define most common words for boosting
|
|
super_common = {'the', 'and', 'you', 'it', 'is', 'at', 'to', 'he', 'we', 'me', 'be', 'my', 'by', 'up', 'if', 'an', 'as', 'or', 'on', 'in', 'of', 'a', 'i'}
|
|
|
|
# Count phonemes in input sequence for phonetic accuracy scoring
|
|
input_phonemes = len(viseme_sequence.replace('_', ''))
|
|
|
|
for word, confidence in matches:
|
|
# More lenient confidence thresholds
|
|
min_confidence = 0.3 if len(word) <= 6 else 0.4
|
|
if confidence > min_confidence and len(word) <= 10 and word.isalpha():
|
|
# Calculate word score prioritizing phonetic accuracy over brevity
|
|
word_score = confidence
|
|
|
|
# MAJOR BONUS: Phonetic accuracy (prioritize words that match input phoneme count)
|
|
word_phonemes = len(word) # Approximate phoneme count
|
|
phoneme_accuracy = 1.0 - abs(input_phonemes - word_phonemes) / max(input_phonemes, word_phonemes, 1)
|
|
|
|
# Give massive bonus for phonetic accuracy - this should dominate scoring
|
|
if phoneme_accuracy > 0.8: # Very close match
|
|
word_score += 1.0 # Huge bonus for phonetically accurate words
|
|
elif phoneme_accuracy > 0.6: # Good match
|
|
word_score += 0.7 # Large bonus
|
|
elif phoneme_accuracy > 0.4: # Reasonable match
|
|
word_score += 0.4 # Moderate bonus
|
|
|
|
# Reduce length bias - only tiny preference for shorter words
|
|
if len(word) <= 3:
|
|
word_score += 0.05 # Very small bonus
|
|
elif len(word) <= 4:
|
|
word_score += 0.02 # Minimal bonus
|
|
|
|
# Common word bonus (small boost for super common words)
|
|
if word in super_common:
|
|
word_score += 0.1 # Small boost for common words
|
|
|
|
# Natural word bonus - boost common English words
|
|
if len(word) >= 3 and any(c in 'aeiou' for c in word.lower()):
|
|
word_score += 0.15 # Boost words with vowels (more natural)
|
|
|
|
good_matches.append((word, word_score))
|
|
|
|
if good_matches:
|
|
# Sort by score and return only the best match
|
|
good_matches.sort(key=lambda x: x[1], reverse=True)
|
|
best_word = good_matches[0][0]
|
|
return [best_word]
|
|
|
|
# If no good matches, try simplified patterns for common words
|
|
clean_sequence = viseme_sequence.replace('_', '').strip()
|
|
|
|
# Special handling for common repeated patterns and long sequences
|
|
# Count characters for pattern analysis
|
|
if len(clean_sequence) >= 3:
|
|
# Count dominant characters
|
|
char_counts = {}
|
|
for char in clean_sequence:
|
|
char_counts[char] = char_counts.get(char, 0) + 1
|
|
|
|
# Find most common character
|
|
dominant_char = max(char_counts.items(), key=lambda x: x[1])[0]
|
|
dominant_ratio = char_counts[dominant_char] / len(clean_sequence)
|
|
dominant_count = char_counts[dominant_char]
|
|
|
|
# For very long sequences (4+), if one character dominates (>60%), use expanded alternatives
|
|
if len(clean_sequence) >= 4 and dominant_ratio > 0.6:
|
|
char_alternatives = {
|
|
'I': ['it', 'if', 'is', 'in', 'ice', 'ivy', 'idea'],
|
|
'A': ['at', 'as', 'an', 'ah', 'apple', 'army', 'angry'],
|
|
'E': ['eh', 'every', 'energy', 'eleven', 'exit', 'empty'],
|
|
'O': ['oh', 'on', 'or', 'ox', 'open', 'only', 'often'],
|
|
'U': ['uh', 'up', 'us', 'um', 'under', 'until', 'ugly'],
|
|
'B': ['be', 'by', 'big', 'baby', 'maybe', 'about', 'but', 'boy', 'bad', 'buy', 'bay', 'bow'],
|
|
'P': ['pah', 'pop', 'put', 'pay', 'paper', 'happy', 'apple', 'top', 'up'],
|
|
'M': ['mm', 'me', 'my', 'may', 'maybe', 'more', 'many', 'man', 'make', 'come'],
|
|
'F': ['ff', 'of', 'if', 'for', 'family', 'forty', 'coffee', 'life', 'off'],
|
|
'V': ['vv', 'very', 'have', 'every', 'seven', 'never', 'give', 'love'],
|
|
'T': ['tt', 'to', 'the', 'try', 'water', 'little', 'better', 'time', 'take'],
|
|
'D': ['dd', 'do', 'day', 'dig', 'today', 'under', 'order', 'good', 'made'],
|
|
'K': ['kay', 'key', 'can', 'back', 'take', 'like', 'work', 'look', 'make'],
|
|
'G': ['go', 'got', 'big', 'give', 'good', 'again', 'right', 'get', 'great'],
|
|
'N': ['no', 'new', 'now', 'can', 'than', 'when', 'then', 'any', 'man']
|
|
}
|
|
|
|
if dominant_char in char_alternatives:
|
|
alternatives = char_alternatives[dominant_char]
|
|
# Use pattern characteristics to pick different alternatives
|
|
alt_index = (len(clean_sequence) + dominant_count) % len(alternatives)
|
|
return [alternatives[alt_index]]
|
|
|
|
# Handle repeated patterns systematically - find phonetically coherent words
|
|
|
|
# For patterns like EEEE or IIIII - prioritize words that phonetically make sense
|
|
if dominant_count >= 3: # Strong pattern (3+ of same character)
|
|
# Find words where the dominant character is actually prominent in pronunciation
|
|
phonetically_coherent_words = []
|
|
|
|
for pattern, words in self.phoneme_patterns.items():
|
|
if dominant_char in pattern:
|
|
char_density = pattern.count(dominant_char) / len(pattern)
|
|
# Look for patterns where this character is prominent (>40% of pattern)
|
|
if char_density >= 0.4:
|
|
# Filter words that make phonetic sense
|
|
for word in words[:3]: # Top 3 from each pattern
|
|
word_char_count = word.lower().count(dominant_char.lower())
|
|
word_char_density = word_char_count / len(word)
|
|
|
|
# Word should have decent occurrence of the character
|
|
if word_char_count >= 2 and word_char_density >= 0.25:
|
|
phonetically_coherent_words.append((word, char_density, word_char_density))
|
|
|
|
if phonetically_coherent_words:
|
|
# Sort by phonetic coherence (pattern density + word density)
|
|
phonetically_coherent_words.sort(key=lambda x: x[1] + x[2], reverse=True)
|
|
# Use sequence properties to pick variety
|
|
word_index = (len(clean_sequence) * ord(dominant_char)) % len(phonetically_coherent_words)
|
|
chosen_word = phonetically_coherent_words[word_index][0]
|
|
return [chosen_word]
|
|
|
|
# Fallback: improved character alternatives with better phonetic choices
|
|
char_alternatives = {
|
|
'I': ['it', 'if', 'is', 'in', 'ice', 'ivy', 'idea'],
|
|
'A': ['at', 'as', 'an', 'ah', 'apple', 'army', 'angry'],
|
|
'E': ['eh', 'every', 'energy', 'eleven', 'exit', 'empty'],
|
|
'O': ['oh', 'on', 'or', 'ox', 'open', 'only', 'often'],
|
|
'U': ['uh', 'up', 'us', 'um', 'under', 'until', 'ugly'],
|
|
'B': ['be', 'by', 'big', 'baby', 'maybe', 'about', 'but', 'boy', 'bad', 'buy', 'bay', 'bow'],
|
|
'P': ['pah', 'pop', 'put', 'pay', 'paper', 'happy', 'apple', 'top', 'up'],
|
|
'M': ['mm', 'me', 'my', 'may', 'maybe', 'more', 'many', 'man', 'make', 'come'],
|
|
'F': ['ff', 'of', 'if', 'for', 'family', 'forty', 'coffee', 'life', 'off'],
|
|
'V': ['vv', 'very', 'have', 'every', 'seven', 'never', 'give', 'love'],
|
|
'T': ['tt', 'to', 'the', 'try', 'water', 'little', 'better', 'time', 'take'],
|
|
'D': ['dd', 'do', 'day', 'dig', 'today', 'under', 'order', 'good', 'made'],
|
|
'K': ['kay', 'key', 'can', 'back', 'take', 'like', 'work', 'look', 'make'],
|
|
'G': ['go', 'got', 'big', 'give', 'good', 'again', 'right', 'get', 'great'],
|
|
'N': ['no', 'new', 'now', 'can', 'than', 'when', 'then', 'any', 'man']
|
|
}
|
|
|
|
if dominant_char in char_alternatives:
|
|
alternatives = char_alternatives[dominant_char]
|
|
# Use pattern characteristics to pick different alternatives
|
|
alt_index = (len(clean_sequence) + dominant_count) % len(alternatives)
|
|
return [alternatives[alt_index]]
|
|
|
|
# For very short sequences, just return cleaned
|
|
if len(clean_sequence) <= 2:
|
|
return [clean_sequence]
|
|
else:
|
|
return [viseme_sequence] # Show original with underscores
|
|
|
|
|
|
# Global instance for reuse
|
|
_phoneme_matcher = None
|
|
|
|
def get_phoneme_matcher() -> PhonemeWordMatcher:
|
|
"""Get global phoneme matcher instance"""
|
|
global _phoneme_matcher
|
|
if _phoneme_matcher is None:
|
|
_phoneme_matcher = PhonemeWordMatcher()
|
|
return _phoneme_matcher |