1010import subprocess
1111import threading
1212from collections .abc import Callable
13+ from dataclasses import dataclass
1314from functools import lru_cache
1415from typing import Any
1516
@@ -43,6 +44,98 @@ def clean_search_terms(search_term: str) -> str:
4344 return text
4445
4546
47+ @dataclass
48+ class PronunciationInfo :
49+ ipa : str
50+ is_fallback : bool = False
51+
52+
53+ @dataclass
54+ class PronunciationGroup :
55+ pronunciation : PronunciationInfo | None
56+ synsets : list [dict [str , Any ]]
57+
58+
59+ @dataclass
60+ class LemmaGroup :
61+ lemma : str
62+ pronunciation_groups : list [PronunciationGroup ]
63+
64+
65+ def _normalize_pronunciation_variety (variety : str | None ) -> str :
66+ if not variety :
67+ return ""
68+ return variety .strip ().casefold ()
69+
70+
71+ def _pronunciation_group_key (pronunciation : PronunciationInfo | None ) -> tuple [str , bool ]:
72+ if pronunciation is None :
73+ return ("" , False )
74+ return (pronunciation .ipa , pronunciation .is_fallback )
75+
76+
77+ def group_synsets_by_lemma (synsets : list [dict [str , Any ]]) -> list [LemmaGroup ]:
78+ lemma_groups : dict [str , dict [tuple [str , bool ], list [dict [str , Any ]]]] = {}
79+
80+ for synset in synsets :
81+ lemma = synset ["name" ]
82+ pronunciation = synset .get ("pronunciation" )
83+ pronunciation_groups = lemma_groups .setdefault (lemma , {})
84+ pronunciation_groups .setdefault (_pronunciation_group_key (pronunciation ), []).append (synset )
85+
86+ return [
87+ LemmaGroup (
88+ lemma = lemma ,
89+ pronunciation_groups = [
90+ PronunciationGroup (
91+ pronunciation = group_synsets [0 ].get ("pronunciation" ),
92+ synsets = group_synsets ,
93+ )
94+ for group_synsets in pronunciation_groups .values ()
95+ ],
96+ )
97+ for lemma , pronunciation_groups in lemma_groups .items ()
98+ ]
99+
100+
101+ def ipa_to_espeak (ipa_string : str ) -> str :
102+ s = ipa_string .strip ().strip ("/[]" )
103+
104+ mapping = {
105+ "ˈ" : "'" , "ˌ" : "," , "ː" : ":" , "." : "" , "‿" : "" , "|" : "" , "‖" : "" ,
106+ "tʃ" : "tS" , "dʒ" : "dZ" ,
107+ "eɪ" : "eI" , "aɪ" : "aI" , "ɔɪ" : "OI" , "aʊ" : "aU" , "oʊ" : "oU" , "əʊ" : "@U" ,
108+ "ɪə" : "I@" , "eə" : "e@" , "ɛə" : "e@" , "ʊə" : "U@" ,
109+ "iː" : "i:" , "ɑː" : "A:" , "ɔː" : "O:" , "uː" : "u:" , "ɜː" : "3:" ,
110+ "ɚ" : "@r" , "ɝ" : "3r" ,
111+ "ɪ" : "I" , "ɛ" : "E" , "e" : "e" , "æ" : "a" , "ɑ" : "A" , "ɒ" : "0" , "ɔ" : "O" ,
112+ "ʊ" : "U" , "ʌ" : "V" , "ɜ" : "3" , "ə" : "@" , "ɐ" : "@" , "a" : "a" ,
113+ "θ" : "T" , "ð" : "D" , "ʃ" : "S" , "ʒ" : "Z" , "ŋ" : "N" , "ɹ" : "r" , "ɾ" : "4" ,
114+ "ɫ" : "l" , "ʔ" : "?" , "ʍ" : "W" , "x" : "x" , "ç" : "C" ,
115+ }
116+
117+ for ipa_sym , esp_sym in sorted (mapping .items (), key = lambda x : len (x [0 ]), reverse = True ):
118+ s = s .replace (ipa_sym , esp_sym )
119+
120+ s = "" .join (c for c in s if ord (c ) < 128 )
121+ return f"[[{ s } ]]"
122+
123+
124+ def _pick_pronunciation (prons : list [wn .Pronunciation ], accent : str ) -> PronunciationInfo | None :
125+ if not prons :
126+ return None
127+
128+ requested_variety = _normalize_pronunciation_variety (accent )
129+
130+ for p in prons :
131+ pronunciation_variety = _normalize_pronunciation_variety (p .variety )
132+ if pronunciation_variety and pronunciation_variety == requested_variety :
133+ return PronunciationInfo (ipa = p .value )
134+
135+ p = prons [0 ]
136+ return PronunciationInfo (ipa = p .value )
137+
138+
46139def create_required_dirs () -> None :
47140 """Make required directories if they don't already exist."""
48141 os .makedirs (utils .CONFIG_DIR , exist_ok = True )
@@ -60,21 +153,38 @@ def fetch_definition(term: str, wn_instance: wn.Wordnet, accent: str = "us") ->
60153 accent: The espeak-ng accent code.
61154
62155 Returns:
63- A dictionary containing the definition data with pronunciation information .
156+ A dictionary containing the definition data without a top-level pronunciation .
64157 """
65- definition_data = get_definition (term , wn_instance )
158+ with WN_DATABASE_LOCK :
159+ definition_data = get_definition (term , wn_instance , accent = accent )
66160
67- pronunciation_term = definition_data .get ("term" ) or term
68- pron = get_pronunciation (pronunciation_term , accent )
69- final_pron = pron if pron and not pron .isspace () else "Pronunciation unavailable (is espeak-ng installed?)"
161+ result = definition_data .get ("result" )
162+ resolved_term = definition_data .get ("term" , term )
70163
71- final_data : dict [str , Any ] = {
72- "term" : definition_data .get ("term" , term ),
73- "pronunciation" : final_pron ,
74- "result" : definition_data .get ("result" ),
75- }
164+ if not result or not resolved_term :
165+ return definition_data
76166
77- return final_data
167+ normalized_resolved_term = resolved_term .casefold ()
168+ needs_fallback = any (
169+ synset_data .get ("pronunciation" ) is None and synset_data ["name" ].casefold () == normalized_resolved_term
170+ for pos_synsets in result .values ()
171+ for synset_data in pos_synsets
172+ )
173+
174+ if not needs_fallback :
175+ return definition_data
176+
177+ fallback_ipa = get_pronunciation (resolved_term , accent )
178+ if not fallback_ipa :
179+ return definition_data
180+
181+ fallback_pronunciation = PronunciationInfo (ipa = fallback_ipa , is_fallback = True )
182+ for pos_synsets in result .values ():
183+ for synset_data in pos_synsets :
184+ if synset_data .get ("pronunciation" ) is None and synset_data ["name" ].casefold () == normalized_resolved_term :
185+ synset_data ["pronunciation" ] = fallback_pronunciation
186+
187+ return definition_data
78188
79189
80190def _normalize_lemma (lemma : str ) -> str :
@@ -128,22 +238,22 @@ def _extract_related_lemmas(synset: wn.Synset, matched_lemma: str) -> dict[str,
128238 return related
129239
130240
131- def get_definition (term : str , wn_instance : wn .Wordnet ) -> dict [str , Any ]:
241+ def get_definition (term : str , wn_instance : wn .Wordnet , accent : str = "us" ) -> dict [str , Any ]:
132242 """
133243 Gets the definition from WordNet, processes it, and prepares data structure.
134244
135245 Args:
136246 term: The term to define.
137247 wn_instance: The initialized Wordnet instance.
248+ accent: The espeak-ng accent code.
138249
139250 Returns:
140251 A dictionary with the processed definition data ('term', 'result').
141252 """
142253 first_match : str | None = None
143254 result_dict : dict [str , Any ] = {pos : [] for pos in POS_MAP .values ()}
144255
145- with WN_DATABASE_LOCK :
146- synsets = wn_instance .synsets (term .lower ())
256+ synsets = wn_instance .synsets (term .lower ())
147257
148258 if not synsets :
149259 clean_def = {"term" : term , "result" : None }
@@ -164,12 +274,20 @@ def get_definition(term: str, wn_instance: wn.Wordnet) -> dict[str, Any]:
164274 if first_match is None :
165275 first_match = matched_lemma
166276
277+ wn_pron = None
278+ for word in synset .words ():
279+ if _normalize_lemma (word .lemma (data = False )).lower () == matched_lemma .lower ():
280+ form = word .lemma (data = True )
281+ wn_pron = _pick_pronunciation (form .pronunciations (), accent )
282+ break
283+
167284 related_lemmas = _extract_related_lemmas (synset , matched_lemma )
168285
169286 synset_data : dict [str , Any ] = {
170287 "name" : matched_lemma ,
171288 "definition" : synset .definition () or "No definition available." ,
172289 "examples" : synset .examples () or [],
290+ "pronunciation" : wn_pron ,
173291 ** related_lemmas ,
174292 }
175293
@@ -192,7 +310,7 @@ def get_pronunciation(term: str, accent: str = "us") -> str | None:
192310 accent: The espeak-ng accent code (e.g., "us", "gb").
193311
194312 Returns:
195- The pronunciation in IPA format (e.g., "/tˈɛst/") , or None if espeak-ng fails.
313+ The pronunciation in IPA format without wrapper slashes , or None if espeak-ng fails.
196314 """
197315 try :
198316 process = subprocess .Popen (
@@ -212,7 +330,7 @@ def get_pronunciation(term: str, accent: str = "us") -> str | None:
212330
213331 if process .returncode == 0 and stdout :
214332 ipa_pronunciation = stdout .strip ().replace ("\n " , " " ).replace (" " , " " )
215- return f"/ { ipa_pronunciation .strip ('/' ) } /"
333+ return ipa_pronunciation .strip ("/" )
216334
217335 utils .log_warning (f"espeak-ng failed for term '{ term } '. RC: { process .returncode } . Stderr: { stderr .strip ()} " )
218336 return None
@@ -287,7 +405,6 @@ def fetch():
287405def format_output (text : str , wn_instance : wn .Wordnet , accent : str = "us" ) -> dict [str , Any ] | None :
288406 """
289407 Determines colors, handles special commands (fortune, exit), and fetches definitions.
290- Uses WN_DATABASE_LOCK to prevent concurrent access with wordlist loading.
291408
292409 Args:
293410 text: The input text (search term or command).
@@ -311,18 +428,24 @@ def format_output(text: str, wn_instance: wn.Wordnet, accent: str = "us") -> dic
311428 return None
312429
313430
314- def read_term (text : str , speed : int = 120 , accent : str = "us" ) -> None :
431+ def read_term (text : str , speed : int = 120 , accent : str = "us" , ipa : str | None = None ) -> None :
315432 """
316433 Uses espeak-ng to speak the given text aloud.
317434
318435 Args:
319436 text: The text to speak.
320437 speed: Speaking speed (words per minute).
321438 accent: The espeak-ng accent code.
439+ ipa: The IPA string to use for pronunciation (if available).
322440 """
441+ if ipa :
442+ phoneme_input = ipa_to_espeak (ipa )
443+ else :
444+ phoneme_input = text
445+
323446 try :
324447 subprocess .run (
325- ["espeak-ng" , "-s" , str (speed ), "-v" , f"en-{ accent } " , text ],
448+ ["espeak-ng" , "-s" , str (speed ), "-v" , f"en-{ accent } " , phoneme_input ],
326449 stdout = subprocess .DEVNULL ,
327450 stderr = subprocess .PIPE ,
328451 check = False ,
0 commit comments