3v324v23 commited on
Commit
3bc129f
·
1 Parent(s): ec553ac

Enhance pronunciation scoring: add Hebrew transliteration support and update tips with Hebrew context

Browse files
Files changed (5) hide show
  1. .gitignore +2 -0
  2. app/scorer.py +5 -2
  3. app/tips.py +11 -1
  4. app/translit.py +68 -0
  5. static/index.html +3 -3
.gitignore CHANGED
@@ -1 +1,3 @@
1
  *__pycache__**/
 
 
 
1
  *__pycache__**/
2
+ *.mp3
3
+ *.pcm
app/scorer.py CHANGED
@@ -12,6 +12,7 @@ import numpy as np
12
 
13
  from .align import GopConfig, ctc_viterbi, edit_distance, greedy_decode, score_tokens
14
  from .tips import VOWELS, tip_for
 
15
 
16
  log = logging.getLogger("ihki.scorer")
17
 
@@ -115,6 +116,7 @@ class Scorer:
115
 
116
  phonemes, tips, seen = [], [], set()
117
  ar_words = ar.split()
 
118
  for tok, sc in zip(toks, scores):
119
  heard_ph = "_" if sc.deleted else (self.vocab.i2t.get(sc.competitor) if sc.competitor is not None else None)
120
  flagged = bool(sc.score < self.cfg.flag_below) and tok.phoneme not in VOWELS
@@ -125,13 +127,14 @@ class Scorer:
125
  if tip and tip["key"] not in seen:
126
  seen.add(tip["key"])
127
  tip["word"] = ar_words[tok.word] if tok.word < len(ar_words) else ""
 
128
  tips.append(tip)
129
 
130
  n_words = len(ipa.split("|"))
131
  words = []
132
  for w in range(n_words):
133
  ws = [p["score"] for p in phonemes if p["word"] == w]
134
- words.append({"ar": ar_words[w] if w < len(ar_words) else "",
135
  "score": round(min(ws), 1) if ws else 0.0,
136
  "phonemes": [p for p in phonemes if p["word"] == w]})
137
 
@@ -141,7 +144,7 @@ class Scorer:
141
  overall = 0.5 * phone_mean + 0.5 * word_mean # one wrong consonant must pull the sentence down
142
  if unrelated:
143
  overall = min(overall, 30.0)
144
- return {"score": round(overall), "variant": {"ar": ar, "ipa": ipa}, "heard_ipa": heard,
145
  "unrelated": unrelated, "words": words, "tips": tips[:4]}
146
 
147
 
 
12
 
13
  from .align import GopConfig, ctc_viterbi, edit_distance, greedy_decode, score_tokens
14
  from .tips import VOWELS, tip_for
15
+ from .translit import to_hebrew
16
 
17
  log = logging.getLogger("ihki.scorer")
18
 
 
116
 
117
  phonemes, tips, seen = [], [], set()
118
  ar_words = ar.split()
119
+ tr_words = to_hebrew(ipa, ar)
120
  for tok, sc in zip(toks, scores):
121
  heard_ph = "_" if sc.deleted else (self.vocab.i2t.get(sc.competitor) if sc.competitor is not None else None)
122
  flagged = bool(sc.score < self.cfg.flag_below) and tok.phoneme not in VOWELS
 
127
  if tip and tip["key"] not in seen:
128
  seen.add(tip["key"])
129
  tip["word"] = ar_words[tok.word] if tok.word < len(ar_words) else ""
130
+ tip["word_tr"] = tr_words[tok.word]
131
  tips.append(tip)
132
 
133
  n_words = len(ipa.split("|"))
134
  words = []
135
  for w in range(n_words):
136
  ws = [p["score"] for p in phonemes if p["word"] == w]
137
+ words.append({"ar": ar_words[w] if w < len(ar_words) else "", "tr": tr_words[w],
138
  "score": round(min(ws), 1) if ws else 0.0,
139
  "phonemes": [p for p in phonemes if p["word"] == w]})
140
 
 
144
  overall = 0.5 * phone_mean + 0.5 * word_mean # one wrong consonant must pull the sentence down
145
  if unrelated:
146
  overall = min(overall, 30.0)
147
+ return {"score": round(overall), "variant": {"ar": ar, "tr": " ".join(tr_words), "ipa": ipa}, "heard_ipa": heard,
148
  "unrelated": unrelated, "words": words, "tips": tips[:4]}
149
 
150
 
app/tips.py CHANGED
@@ -3,6 +3,7 @@
3
  Keys are (expected phoneme, heard phoneme); "_" means the expected phoneme was missing.
4
  Phonemes are in the IPA used by phrases.py; HEARD_ALIASES maps model outputs onto those symbols.
5
  """
 
6
 
7
  NAMES = {
8
  "ħ": "ח (ح)", "ʕ": "ע (ع)", "x": "ח' (خ)", "ɣ": "ע' (غ)", "r": "ר (ر)",
@@ -46,6 +47,15 @@ TIPS = {
46
  ("ðˤ", "ð"): "נהגתה בלי הדגשה. ظ היא עיצור מודגש: " + _EMPH,
47
  }
48
 
 
 
 
 
 
 
 
 
 
49
  VOWELS = {"a", "i", "u", "e", "o", "aː", "iː", "uː", "eː", "oː"}
50
  # the urban glottal stop is often dropped by natives too; don't nag about it
51
  NO_DELETION_TIP = {"ʔ"}
@@ -61,7 +71,7 @@ def tip_for(expected: str, heard: str | None) -> dict | None:
61
  name = NAMES.get(expected, expected)
62
  text = TIPS.get((expected, heard))
63
  if text:
64
- return {"title": name, "text": text, "key": f"{expected}>{heard}"}
65
  if heard == "_":
66
  return {"title": name, "text": "לא נשמעה בבירור.", "key": f"{expected}>_"}
67
  text = f"נשמעה כמו {NAMES[heard]}." if heard in NAMES else "נשמעה אחרת מהצפוי."
 
3
  Keys are (expected phoneme, heard phoneme); "_" means the expected phoneme was missing.
4
  Phonemes are in the IPA used by phrases.py; HEARD_ALIASES maps model outputs onto those symbols.
5
  """
6
+ import re
7
 
8
  NAMES = {
9
  "ħ": "ח (ح)", "ʕ": "ע (ع)", "x": "ח' (خ)", "ɣ": "ע' (غ)", "r": "ר (ر)",
 
47
  ("ðˤ", "ð"): "נהגתה בלי הדגשה. ظ היא עיצור מודגש: " + _EMPH,
48
  }
49
 
50
+ # bare Arabic letter in a tip text -> "Hebrew (Arabic)", so Arabic never appears without a Hebrew reading
51
+ _PAIRED = {name[name.index("(") + 1]: name for name in NAMES.values() if "/" not in name}
52
+ _BARE_AR = re.compile(r"(?<!\()[\u0621-\u064a](?![\u0621-\u064a])")
53
+
54
+
55
+ def with_hebrew(text: str) -> str:
56
+ return _BARE_AR.sub(lambda m: _PAIRED.get(m.group(), m.group()), text)
57
+
58
+
59
  VOWELS = {"a", "i", "u", "e", "o", "aː", "iː", "uː", "eː", "oː"}
60
  # the urban glottal stop is often dropped by natives too; don't nag about it
61
  NO_DELETION_TIP = {"ʔ"}
 
71
  name = NAMES.get(expected, expected)
72
  text = TIPS.get((expected, heard))
73
  if text:
74
+ return {"title": name, "text": with_hebrew(text), "key": f"{expected}>{heard}"}
75
  if heard == "_":
76
  return {"title": name, "text": "לא נשמעה בבירור.", "key": f"{expected}>_"}
77
  text = f"נשמעה כמו {NAMES[heard]}." if heard in NAMES else "נשמעה אחרת מהצפוי."
app/translit.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hebrew transliteration generated from the IPA in phrases.py.
2
+
3
+ Used wherever Arabic is shown for a specific variant or word, so every variant gets a reading
4
+ without hand-writing one. Follows the conventions of the hand-written `tr` hints: pointed Hebrew,
5
+ geresh for sounds Hebrew lacks (ח' ע' ג' ד' ט'), dagesh for geminates.
6
+ """
7
+
8
+ CONSONANTS = {
9
+ "ʔ": "א", "b": "בּ", "t": "ת", "dʒ": "ג'", "ħ": "ח", "x": "ח'", "d": "ד", "r": "ר", "z": "ז",
10
+ "s": "ס", "ʃ": "ש", "sˤ": "צ", "dˤ": "ד'", "tˤ": "ט", "ðˤ": "ט'", "ʕ": "ע", "ɣ": "ע'", "f": "פ",
11
+ "q": "ק", "k": "כּ", "g": "ג", "l": "ל", "m": "מ", "n": "נ", "h": "ה", "w": "ו", "j": "י",
12
+ }
13
+ # vowel -> ((mark on the consonant, letter after it) mid-word, (same) word-finally)
14
+ VOWELS = {
15
+ "a": (("ַ", ""), ("ַ", "א")), "i": (("ִ", ""), ("ִ", "י")), "u": (("ֻ", ""), ("", "וּ")),
16
+ "e": (("ֶ", ""), ("ֶ", "ה")), "o": (("", "וֹ"),) * 2,
17
+ "aː": (("ַ", "א"),) * 2, "iː": (("ִ", "י"),) * 2, "uː": (("", "וּ"),) * 2,
18
+ "eː": (("ֵ", "י"),) * 2, "oː": (("", "וֹ"),) * 2,
19
+ }
20
+ FINALS = {"מ": "ם", "נ": "ן", "צ": "ץ", "פ": "ף"}
21
+ NO_DAGESH = set("אהחער")
22
+ DAGESH, SHVA = "ּ", "ְ"
23
+
24
+
25
+ def _letter(base: str, marks: str) -> str:
26
+ """Points go on the letter itself, before a geresh."""
27
+ return base[0] + marks + base[1:] if base.endswith("'") else base + marks
28
+
29
+
30
+ def word_to_hebrew(ipa_word: str, ar_word: str = "") -> str:
31
+ """The Arabic spelling, when given, settles what the IPA cannot: an urban ʔ that is written ق
32
+ stays ק (as in the hand-written hints), and a final a written ة/ه ends in ה."""
33
+ phs = ipa_word.split()
34
+ if "ق" in ar_word:
35
+ phs = ["q" if ph == "ʔ" else ph for ph in phs]
36
+ final_a = "ה" if ar_word.endswith(("ة", "ه")) else "א"
37
+ out, i = [], 0
38
+ while i < len(phs):
39
+ ph = phs[i]
40
+ if ph in VOWELS: # vowel with no consonant before it (word-initial): carry it on an alef
41
+ base, marks = "א", ""
42
+ else:
43
+ base, marks = CONSONANTS.get(ph, ph), ""
44
+ i += 1
45
+ if i < len(phs) and phs[i] == ph: # geminate
46
+ if base[0] not in NO_DAGESH and DAGESH not in base:
47
+ marks += DAGESH
48
+ i += 1
49
+ if i < len(phs) and phs[i] in VOWELS:
50
+ vowel = phs[i]
51
+ i += 1
52
+ mark, after = VOWELS[vowel][i == len(phs)]
53
+ if vowel == "a" and after:
54
+ after = final_a
55
+ out.append(_letter(base, marks + mark) + after)
56
+ elif i < len(phs):
57
+ out.append(_letter(base, marks + SHVA))
58
+ else:
59
+ out.append(FINALS.get(base, base) if not marks else _letter(base, marks))
60
+ return "".join(out)
61
+
62
+
63
+ def to_hebrew(ipa: str, ar: str = "") -> list[str]:
64
+ """One Hebrew word per IPA word (words are separated by "|")."""
65
+ ipa_words, ar_words = ipa.split("|"), ar.split()
66
+ if len(ar_words) != len(ipa_words):
67
+ ar_words = [""] * len(ipa_words)
68
+ return [word_to_hebrew(w, a) for w, a in zip(ipa_words, ar_words)]
static/index.html CHANGED
@@ -63,7 +63,7 @@ header{display:flex;align-items:baseline;gap:4px 12px;flex-wrap:wrap}
63
  .words{display:flex;flex-wrap:wrap;gap:4px 14px;direction:rtl}
64
  .word{display:flex;flex-direction:column;align-items:center}
65
  .word .w{font:700 26px/1.3 var(--f-ar)}
66
- .word .n{font-size:12px;line-height:1;color:var(--muted);font-variant-numeric:tabular-nums}
67
  .word .w.bad{background:none;color:var(--sumac)} .word .w.mid{background:none;color:var(--amber)} .word .w.good{background:none;color:var(--ink)}
68
  .tips{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
69
  .tips li{padding:6px 10px;border-radius:10px;border:1px solid var(--line);font-size:15px;line-height:1.4}
@@ -163,7 +163,7 @@ function render(r){
163
  for(const w of r.words){
164
  const box=document.createElement("div"); box.className="word";
165
  const ar=document.createElement("div"); ar.className="w "+band(w.score); ar.lang="ar"; ar.textContent=w.ar;
166
- const n=document.createElement("div"); n.className="n"; n.textContent=Math.round(w.score);
167
  box.append(ar,n); words.appendChild(box);
168
  }
169
 
@@ -177,7 +177,7 @@ function render(r){
177
  const li=document.createElement("li");
178
  const what=document.createElement("span"); what.className="what";
179
  what.append(t.title+" במילה ");
180
- const w=document.createElement("span"); w.className="ar"; w.textContent=t.word; what.append(w);
181
  const body=document.createElement("span"); body.textContent=t.text;
182
  li.append(what,body); tips.appendChild(li);
183
  }
 
63
  .words{display:flex;flex-wrap:wrap;gap:4px 14px;direction:rtl}
64
  .word{display:flex;flex-direction:column;align-items:center}
65
  .word .w{font:700 26px/1.3 var(--f-ar)}
66
+ .word .n{font-size:14px;line-height:1.5;color:var(--muted);font-variant-numeric:tabular-nums}
67
  .word .w.bad{background:none;color:var(--sumac)} .word .w.mid{background:none;color:var(--amber)} .word .w.good{background:none;color:var(--ink)}
68
  .tips{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}
69
  .tips li{padding:6px 10px;border-radius:10px;border:1px solid var(--line);font-size:15px;line-height:1.4}
 
163
  for(const w of r.words){
164
  const box=document.createElement("div"); box.className="word";
165
  const ar=document.createElement("div"); ar.className="w "+band(w.score); ar.lang="ar"; ar.textContent=w.ar;
166
+ const n=document.createElement("div"); n.className="n"; n.textContent=`${w.tr} · ${Math.round(w.score)}`;
167
  box.append(ar,n); words.appendChild(box);
168
  }
169
 
 
177
  const li=document.createElement("li");
178
  const what=document.createElement("span"); what.className="what";
179
  what.append(t.title+" במילה ");
180
+ const w=document.createElement("span"); w.className="ar"; w.lang="ar"; w.textContent=t.word; what.append(w); if(t.word_tr) what.append(` (${t.word_tr})`);
181
  const body=document.createElement("span"); body.textContent=t.text;
182
  li.append(what,body); tips.appendChild(li);
183
  }