File size: 10,260 Bytes
4eab58f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
"""
Backend-agnostic scoring. Takes a Transcript from any ASR backend plus the
reference passage, returns metrics + an error table.

Nothing in here touches a GPU or the network, which is why the app can run on a
free CPU Space: only transcription needs compute.
"""

from __future__ import annotations

import re
import string
import unicodedata
from functools import lru_cache
from typing import Iterable, Sequence

import pandas as pd

from asr_backends import Transcript, is_nan

# ---------------------------------------------------------------------------
# Optional deps
# ---------------------------------------------------------------------------

try:
    from Levenshtein import distance as _lev
    from Levenshtein import ratio as _ratio
except ImportError:
    import difflib

    def _lev(a: str, b: str) -> int:
        sm = difflib.SequenceMatcher(None, a, b)
        n = max(len(a), len(b))
        return n - int(sm.ratio() * n)

    def _ratio(a: str, b: str) -> float:
        return difflib.SequenceMatcher(None, a, b).ratio()

try:
    from num2words import num2words
except ImportError:
    num2words = None


# ---------------------------------------------------------------------------
# Normalisation -- most "errors" in a naive implementation are encoding noise
# ---------------------------------------------------------------------------

ZERO_WIDTH = dict.fromkeys(map(ord, "\u200b\u200c\u200d\ufeff"), None)
DEVANAGARI_DIGITS = {ord(c): str(i) for i, c in enumerate("०१२३४५६७८९")}
DEV_PUNCT = "।॥"
MATRAS = re.compile(r"[\u0900-\u0903\u093A-\u094F\u0951-\u0957\u0962\u0963]")

CONTRACTIONS = {
    "cant": "cannot", "dont": "do not", "wont": "will not", "im": "i am",
    "ive": "i have", "id": "i would", "ill": "i will", "its": "it is",
    "lets": "let us", "thats": "that is", "youre": "you are", "hes": "he is",
    "shes": "she is", "theyre": "they are", "isnt": "is not", "arent": "are not",
    "wasnt": "was not", "didnt": "did not", "doesnt": "does not",
    "couldnt": "could not", "wouldnt": "would not", "shouldnt": "should not",
}


def _expand_numbers(text: str, lang: str) -> str:
    if num2words is None:
        return text

    def sub(m: re.Match) -> str:
        try:
            return num2words(int(m.group()), lang="hi" if lang == "hi" else "en")
        except Exception:
            return m.group()

    return re.sub(r"\d+", sub, text)


def normalise(text: str, lang: str) -> list[str]:
    """Canonical token list. Order of operations matters."""
    text = unicodedata.normalize("NFC", text)      # unifies the two encodings of ड़
    text = text.translate(ZERO_WIDTH)

    if lang == "hi":
        text = text.translate(DEVANAGARI_DIGITS)
        text = text.replace("ॐ", "ओम")
        text = re.sub(f"[{DEV_PUNCT}]", " ", text)
        text = text.replace("ँ", "ं")               # chandrabindu ~ anusvara
    else:
        text = text.lower().replace("\u2019", "'").replace("-", " ")

    text = _expand_numbers(text, lang)
    text = text.translate(str.maketrans("", "", string.punctuation))
    tokens = text.split()

    if lang == "en":
        tokens = [CONTRACTIONS.get(t, t) for t in tokens]
        tokens = [w for t in tokens for w in t.split()]
    return tokens


def skeleton(word: str, lang: str) -> str:
    """Vowel-stripped form: equal skeletons mean same consonants, wrong vowels."""
    if lang == "hi":
        return MATRAS.sub("", word)
    return re.sub(r"[aeiou]", "", word) or word


@lru_cache(maxsize=200_000)
def similarity(a: str, b: str) -> float:
    return _ratio(a, b)


# ---------------------------------------------------------------------------
# Alignment: Needleman-Wunsch weighted by edit distance
# ---------------------------------------------------------------------------
# difflib only matches byte-identical tokens, so बिगडा vs बिगड़ा becomes a
# delete + insert and the two words are never compared to each other.

GAP_COST = 0.62     # < 1.0 so a near-match always beats delete + insert


def align(ref: Sequence[str], hyp: Sequence[str]) -> list[tuple[str | None, str | None]]:
    n, m = len(ref), len(hyp)
    dist = [[0.0] * (m + 1) for _ in range(n + 1)]
    back = [[""] * (m + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        dist[i][0], back[i][0] = i * GAP_COST, "D"
    for j in range(1, m + 1):
        dist[0][j], back[0][j] = j * GAP_COST, "I"

    for i in range(1, n + 1):
        ri = ref[i - 1]
        for j in range(1, m + 1):
            sub = dist[i - 1][j - 1] + (1.0 - similarity(ri, hyp[j - 1]))
            dele = dist[i - 1][j] + GAP_COST
            ins = dist[i][j - 1] + GAP_COST
            best = min(sub, dele, ins)
            dist[i][j] = best
            back[i][j] = "M" if best == sub else ("D" if best == dele else "I")

    pairs: list[tuple[str | None, str | None]] = []
    i, j = n, m
    while i > 0 or j > 0:
        op = back[i][j] if (i and j) else ("D" if i else "I")
        if op == "M":
            pairs.append((ref[i - 1], hyp[j - 1])); i -= 1; j -= 1
        elif op == "D":
            pairs.append((ref[i - 1], None)); i -= 1
        else:
            pairs.append((None, hyp[j - 1])); j -= 1
    pairs.reverse()
    return pairs


# ---------------------------------------------------------------------------
# Error taxonomy
# ---------------------------------------------------------------------------

SIMILAR_HI = [set("बवभ"), set("सशष"), set("दध"), set("तट"), set("कख"), set("गघ"),
              set("जझ"), set("पफ"), set("नण"), set("रड़"), set("लर")]
SIMILAR_EN = [set("bvp"), set("sz"), set("td"), set("kg"), set("fp"), set("lr"),
              set("mn"), set("jy")]

LABELS = {
    "extra":         ("अतिरिक्त शब्द", "Extra word"),
    "omission":      ("छूटा हुआ शब्द", "Omitted word"),
    "vowel":         ("मात्रा दोष", "Vowel error"),
    "phonetic":      ("ध्वनि भ्रम", "Confusable sound"),
    "pronunciation": ("उच्चारण दोष", "Mispronounced"),
    "order":         ("अक्षर क्रम", "Letter order"),
    "substitution":  ("गलत शब्द", "Wrong word"),
}

SEVERITY = {"ok": 0, "vowel": 1, "phonetic": 1, "pronunciation": 2, "order": 2,
            "omission": 3, "extra": 3, "substitution": 4}


def _label(code: str, lang: str) -> str:
    hi, en = LABELS[code]
    return f"{hi} / {en}" if lang == "hi" else en


def classify(ref: str | None, hyp: str | None, lang: str) -> str:
    if ref is None:
        return "extra"
    if hyp is None:
        return "omission"
    if ref == hyp:
        return "ok"

    ed = _lev(ref, hyp)
    if skeleton(ref, lang) == skeleton(hyp, lang):
        return "vowel"

    groups = SIMILAR_HI if lang == "hi" else SIMILAR_EN
    if ed <= 2 and any((set(ref) & g) and (set(hyp) & g) for g in groups):
        return "phonetic"
    if similarity(ref, hyp) >= 0.75 or ed <= 2:
        return "pronunciation"
    if sorted(ref) == sorted(hyp):
        return "order"
    return "substitution"


# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------

def cer(ref_tokens: Iterable[str], hyp_tokens: Iterable[str]) -> float:
    r, h = " ".join(ref_tokens), " ".join(hyp_tokens)
    return _lev(r, h) / max(1, len(r))


def score(expected: str, tr: Transcript, lang: str) -> tuple[dict, pd.DataFrame]:
    ref = normalise(expected, lang)
    hyp = normalise(tr.text, lang)

    if not ref:
        return {"error": "The passage is empty."}, pd.DataFrame()

    pairs = align(ref, hyp)

    # map normalised hypothesis token -> ASR confidence, when the backend has it
    conf: dict[str, float] = {}
    if tr.has_confidence:
        for w in tr.words:
            toks = normalise(w.text, lang)
            if toks:
                conf.setdefault(toks[0], w.prob)

    rows, sub, dele, ins, soft = [], 0, 0, 0, 0.0
    for r, h in pairs:
        code = classify(r, h, lang)
        if code == "ok":
            soft += 1.0
            continue
        if code == "extra":
            ins += 1
        elif code == "omission":
            dele += 1
        else:
            sub += 1
            soft += similarity(r, h)          # partial credit for a near miss

        row = {
            "अपेक्षित / Expected": r or "",
            "सुना गया / Heard": h or "",
            "प्रकार / Error type": _label(code, lang),
            "समानता / Similarity": round(similarity(r or "", h or ""), 2),
        }
        if tr.has_confidence:
            c = conf.get(h) if h else None
            row["ASR conf."] = None if (c is None or is_nan(c)) else round(c, 2)
        row["_sev"] = SEVERITY[code]
        rows.append(row)

    n = len(ref)
    wer = (sub + dele + ins) / n
    exact = 100.0 * max(0, n - sub - dele) / n
    lenient = 100.0 * soft / n

    dur = tr.speech_seconds or (
        tr.words[-1].end - tr.words[0].start if len(tr.words) > 1 else 0.0)
    wpm = round(60.0 * len(hyp) / dur, 1) if dur > 0.5 else None

    pauses = sum(1 for a, b in zip(tr.words, tr.words[1:]) if b.start - a.end > 0.7)

    metrics = {
        "📝 Transcribed": tr.text,
        "✅ Word accuracy (%)": round(exact, 2),
        "🎯 Lenient score (%)": round(lenient, 2),
        "📉 WER (%)": round(100 * wer, 2),
        "🔤 CER (%)": round(100 * cer(ref, hyp), 2),
        "⏱️ Speaking rate (wpm)": wpm,
        "⏸️ Long pauses (>0.7s)": pauses if tr.words else "n/a",
        "🔢 Errors": {"substitutions": sub, "omissions": dele, "insertions": ins},
        "⚙️ Backend": f"{tr.backend}:{tr.model} ({tr.latency_s}s)",
    }
    if tr.has_confidence:
        unclear = [w.text for w in tr.words if not is_nan(w.prob) and w.prob < 0.45]
        metrics["🤔 Unclear words"] = unclear[:10] or "—"

    df = pd.DataFrame(rows)
    if not df.empty:
        df = (df.sort_values("_sev", ascending=False)
                .drop(columns="_sev")
                .reset_index(drop=True))
    return metrics, df