Enhance pronunciation scoring: add Hebrew transliteration support and update tips with Hebrew context
Browse files- .gitignore +2 -0
- app/scorer.py +5 -2
- app/tips.py +11 -1
- app/translit.py +68 -0
- 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:
|
| 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 |
}
|