Skip to content

Commit 24827e3

Browse files
committed
feat: make use of wn pronunciations where available
- wn is semantically aware, unlike espeak, so yeah - group definitions by pronunciations (try bass, for example) - clearly label when using espeak as fallback - some ui enhancements
1 parent 3885630 commit 24827e3

5 files changed

Lines changed: 384 additions & 217 deletions

File tree

data/resources/style.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ listbox row.favorite-item:selected {
3131
color: @accent_color;
3232
}
3333

34-
.term-view,
3534
.pos-header {
3635
font-size: large;
3736
font-weight: bold;
3837
}
3938

40-
.pronunciation-view {
39+
.pronunciation-inline {
4140
font-style: italic;
41+
font-size: 0.95em;
4242
}
4343

4444
.relation-type,

data/resources/ui/window.blp

Lines changed: 2 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ template $WordbookWindow: Adw.ApplicationWindow {
163163

164164
Button search_button {
165165
icon-name: "edit-find-symbolic";
166+
receives-default: true;
166167
tooltip-text: _("Search");
167168

168169
accessibility {
@@ -223,73 +224,12 @@ template $WordbookWindow: Adw.ApplicationWindow {
223224
child: Box {
224225
orientation: vertical;
225226

226-
Box {
227-
hexpand: false;
228-
229-
Box {
230-
margin-start: 18;
231-
margin-end: 18;
232-
margin-top: 12;
233-
margin-bottom: 16;
234-
orientation: vertical;
235-
hexpand: false;
236-
237-
Label term_view {
238-
label: "Term";
239-
use-markup: true;
240-
selectable: true;
241-
single-line-mode: true;
242-
ellipsize: end;
243-
xalign: 0;
244-
hexpand: false;
245-
246-
styles [
247-
"term-view",
248-
]
249-
}
250-
251-
Label pronunciation_view {
252-
label: "/Pronunciation/";
253-
use-markup: true;
254-
selectable: true;
255-
ellipsize: end;
256-
single-line-mode: true;
257-
xalign: 0;
258-
hexpand: false;
259-
260-
styles [
261-
"pronunciation-view",
262-
]
263-
}
264-
}
265-
266-
Button speak_button {
267-
margin-start: 4;
268-
margin-end: 12;
269-
margin-top: 12;
270-
margin-bottom: 12;
271-
receives-default: true;
272-
halign: center;
273-
valign: center;
274-
icon-name: "audio-volume-high-symbolic";
275-
has-frame: false;
276-
hexpand: false;
277-
tooltip-text: _("Listen to Pronunciation");
278-
279-
accessibility {
280-
label: _("Listen to Pronunciation");
281-
}
282-
283-
styles [
284-
"circular",
285-
]
286-
}
287-
}
288227

289228
ListBox definitions_listbox {
290229
margin-start: 12;
291230
margin-end: 12;
292231
margin-bottom: 12;
232+
margin-top: 8;
293233
selection-mode: none;
294234
show-separators: false;
295235
vexpand: true;

wordbook/base.py

Lines changed: 142 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import subprocess
1111
import threading
1212
from collections.abc import Callable
13+
from dataclasses import dataclass
1314
from functools import lru_cache
1415
from 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+
46139
def 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

80190
def _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():
287405
def 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,

wordbook/settings_window.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,17 @@ def _on_auto_paste_switch_activate(switch, _gparam):
7171
"""Callback for the 'auto-paste on launch' switch. Saves the new state."""
7272
Settings.get().auto_paste_on_launch = switch.get_active()
7373

74-
@staticmethod
75-
def _on_pronunciations_accent_activate(row, _gparam):
74+
def _on_pronunciations_accent_activate(self, row, _gparam):
7675
"""Callback for the pronunciation accent dropdown. Saves the new selection."""
77-
Settings.get().pronunciations_accent = PronunciationAccent.from_index(row.get_selected())
76+
settings = Settings.get()
77+
previous_accent = settings.pronunciations_accent
78+
new_accent = PronunciationAccent.from_index(row.get_selected())
79+
80+
if new_accent == previous_accent:
81+
return
82+
83+
settings.pronunciations_accent = new_accent
84+
self.parent.refresh_current_search_pronunciations()
7885

7986
@staticmethod
8087
def _on_dark_ui_switch_activate(switch, _gparam):

0 commit comments

Comments
 (0)