File size: 14,557 Bytes
80be62c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
290
291
292
293
294
295
296
297
#!/usr/bin/env python3
"""The text front-end of Swar: raw text -> the letters the model reads.

    from swar.text import normalize, to_ids, VOCAB
    normalize("तपाईंको balance Rs. 250 छ।")  ->  "तपाईंको balance दुई सय पचास rupees छ."

RULES, all enforced here so training and inference cannot drift:
  * Output holds ONLY Devanagari letters/signs, Latin letters, space, apostrophe and
    ordinary punctuation. No tags, no markers, no language switches -- the script
    a letter is written in IS its language.
  * Numbers become words in the language of the text around them (nearest letter
    wins): Devanagari context -> Nepali words, Latin context -> English words.
    Digit runs of 6+ are identifiers (phone, account, OTP) and are read digit by
    digit with "double" (डबल in Nepali).
  * Anything else is dropped (normalize) or makes the sentence unusable
    (is_clean), so a stray symbol can never reach the model as an unknown id.

This file is the vocabulary too: VOCAB is fixed here, and ids are positions in it,
so a checkpoint is only valid with the VOCAB it was trained on (saved inside it).
"""
from __future__ import annotations

import re
import unicodedata

# ---- vocabulary ----------------------------------------------------------------
PAD, BOS, EOS = "<pad>", "<s>", "</s>"            # internal ids, never in text
PUNCT = list(".,?!;:'\"-()…—")
LATIN = [chr(c) for c in range(ord("a"), ord("z") + 1)] + \
        [chr(c) for c in range(ord("A"), ord("Z") + 1)]
# Every Devanagari code point Nepali text uses: U+0900..U+0963 (letters, signs,
# vowel signs, virama, nukta) plus U+0970..U+097F; digits U+0966..U+096F are
# excluded because numbers are always verbalised first; dandas fold to "."
DEVANAGARI = [chr(c) for c in range(0x0900, 0x0964)] + [chr(c) for c in range(0x0970, 0x0980)]
MASK = "<mask>"                                   # stage A (PL-BERT) only; appended LAST so
VOCAB = [PAD, BOS, EOS, " "] + PUNCT + LATIN + DEVANAGARI + [MASK]   # no other id moves
assert len(VOCAB) <= 256, len(VOCAB)
STOI = {s: i for i, s in enumerate(VOCAB)}
ALLOWED = set(VOCAB[3:-1])

# ---- character folding -----------------------------------------------------------
_FOLD = {
    "।": ".", "॥": ".", "’": "'", "‘": "'", "`": "'", "“": '"', "”": '"',
    "–": "-", "‐": "-", "‑": "-", "...": "…", " ": " ",
}
_DROP = dict.fromkeys(map(ord, "​‌‍‎‏"), None)

# ---- English numbers ---------------------------------------------------------------
_ONES = ("zero one two three four five six seven eight nine ten eleven twelve thirteen "
         "fourteen fifteen sixteen seventeen eighteen nineteen").split()
_TENS = "_ _ twenty thirty forty fifty sixty seventy eighty ninety".split()
_SCALES = [(10 ** 9, "billion"), (10 ** 6, "million"), (1000, "thousand")]
_ORD = {"one": "first", "two": "second", "three": "third", "five": "fifth",
        "eight": "eighth", "nine": "ninth", "twelve": "twelfth"}


def en_cardinal(n: int) -> str:
    if n < 20:
        return _ONES[n]
    if n < 100:
        return _TENS[n // 10] + ("" if n % 10 == 0 else "-" + _ONES[n % 10])
    if n < 1000:
        rest = n % 100
        return _ONES[n // 100] + " hundred" + ("" if rest == 0 else " and " + en_cardinal(rest))
    for v, w in _SCALES:
        if n >= v:
            rest = n % v
            head = en_cardinal(n // v) + " " + w
            if rest == 0:
                return head
            return head + (" and " if rest < 100 else " ") + en_cardinal(rest)
    raise ValueError(n)


def en_ordinal(n: int) -> str:
    w = en_cardinal(n)
    last = re.split(r"([ -])", w)
    tail = last[-1]
    if tail in _ORD:
        tail = _ORD[tail]
    elif tail.endswith("y"):
        tail = tail[:-1] + "ieth"
    else:
        tail += "th"
    return "".join(last[:-1]) + tail


def en_digits(run: str) -> str:
    """Identifier reading: 9851005996 -> nine eight five one double zero ..."""
    out, i = [], 0
    while i < len(run):
        if i + 1 < len(run) and run[i] == run[i + 1]:
            out.append("double " + _ONES[int(run[i])])
            i += 2
        else:
            out.append(_ONES[int(run[i])])
            i += 1
    return " ".join(out)


# ---- Nepali numbers ------------------------------------------------------------------
_NE_ONES = ("शून्य एक दुई तीन चार पाँच छ सात आठ नौ दश एघार बाह्र तेह्र चौध पन्ध्र सोह्र सत्र "
            "अठार उन्नाइस बीस एक्काइस बाइस तेइस चौबीस पच्चीस छब्बीस सत्ताइस अठ्ठाइस उनन्तीस "
            "तीस एकतीस बत्तीस तेत्तीस चौँतीस पैँतीस छत्तीस सैँतीस अठतीस उनन्चालीस चालीस "
            "एकचालीस बयालीस त्रिचालीस चवालीस पैँतालीस छयालीस सच्चालीस अठचालीस उनन्चास पचास "
            "एकाउन्न बाउन्न त्रिपन्न चवन्न पचपन्न छपन्न सन्ताउन्न अन्ठाउन्न उनन्साठी साठी "
            "एकसट्ठी बयसट्ठी त्रिसट्ठी चौसट्ठी पैँसट्ठी छयसट्ठी सतसट्ठी अठसट्ठी उनन्सत्तरी सत्तरी "
            "एकहत्तर बहत्तर त्रिहत्तर चौहत्तर पचहत्तर छयहत्तर सतहत्तर अठहत्तर उनासी असी "
            "एकासी बयासी त्रियासी चौरासी पचासी छयासी सतासी अठासी उनान्नब्बे नब्बे "
            "एकानब्बे बयानब्बे त्रियानब्बे चौरानब्बे पन्चानब्बे छयानब्बे सन्तानब्बे अन्ठानब्बे "
            "उनान्सय").split()
assert len(_NE_ONES) == 100, len(_NE_ONES)
_NE_SCALES = [(10 ** 7, "करोड"), (10 ** 5, "लाख"), (1000, "हजार"), (100, "सय")]


def ne_cardinal(n: int) -> str:
    if n < 100:
        return _NE_ONES[n]
    for v, w in _NE_SCALES:
        if n >= v:
            rest = n % v
            head = ne_cardinal(n // v) + " " + w
            return head if rest == 0 else head + " " + ne_cardinal(rest)
    raise ValueError(n)


_NE_DIGIT = dict(zip("0123456789", _NE_ONES[:10]))


def ne_digits(run: str) -> str:
    """'9851005996' -> 'नौ आठ पाँच एक डबल शून्य पाँच डबल नौ छ'"""
    d = run.translate(str.maketrans("०१२३४५६७८९", "0123456789"))
    words, i = [], 0
    while i < len(d):
        if i + 1 < len(d) and d[i] == d[i + 1]:
            words.append("डबल " + _NE_DIGIT[d[i]])
            i += 2
        else:
            words.append(_NE_DIGIT[d[i]])
            i += 1
    return " ".join(words)


# ---- script context -------------------------------------------------------------------
def _is_dev(ch: str) -> bool:
    return 0x0900 <= ord(ch) <= 0x097F and not ("०" <= ch <= "९")


def _context_is_nepali(s: str, start: int, end: int) -> bool:
    """The sentence's majority script decides; the nearest letter only breaks ties.

    "तपाईंको balance Rs. 250 छ" is a Nepali sentence with one English loanword,
    and a Nepali speaker reads its number in Nepali -- nearest-letter alone would
    say English, because "balance" is closer than "छ".
    """
    dev = sum(1 for ch in s if _is_dev(ch))
    lat = sum(1 for ch in s if ch.isascii() and ch.isalpha())
    if dev != lat:
        return dev > lat
    for d in range(1, max(start, len(s) - end) + 1):
        for j in (start - d, end - 1 + d):
            if 0 <= j < len(s) and s[j].isalpha():
                return _is_dev(s[j])
    return False


_CURRENCY = re.compile(r"(?:\b(?:Rs|NPR|NRs)\.?|रु\.?|\$)\s*(?=[0-9०-९])")
_NUM = re.compile(r"[0-9०-९][0-9०-९,]*(?:\.[0-9०-९]+)*(?:st|nd|rd|th)?")
_TIME = re.compile(r"(?<![0-9०-९:])([0-9०-९]{1,2}):([0-9०-९]{2})(?![0-9०-९:])")
_DEV2ASCII = str.maketrans("०१२३४५६७८९", "0123456789")


def _verbalise_numbers(s: str) -> str:
    # currency prefix -> spoken unit after the number ("Rs 250" -> "250 rupees")
    # Placeholders are control characters, NOT letters: a placeholder made of
    # letters would itself be counted by the script-context check.
    def cur(m):
        return "\x01" if not m.group(0).startswith("$") else "\x02"
    s = _CURRENCY.sub(cur, s)
    s = _TIME.sub(lambda m: _time(s, m), s)

    out, last = [], 0
    for m in _NUM.finditer(s):
        try:
            word_or_none = _one_number(s, m, last, out)
        except (ValueError, IndexError):
            word_or_none = None
        if word_or_none is None:
            # Unreadable ("1,4th", "3.2.1"): leave the digits. They are not in
            # VOCAB, so is_clean() rejects the sentence instead of the model
            # hearing a made-up reading.
            out.append(s[last:m.end()])
        last = m.end()
    out.append(s[last:])
    return "".join(out).replace("\x01", "").replace("\x02", "")


def _time(s, m):
    """5:30 -> 'five thirty' / 'पाँच बजेर तीस मिनेट'; 9:00 -> "nine o'clock" / 'नौ बजे'."""
    h, mi = (int(x.translate(_DEV2ASCII)) for x in m.groups())
    if h > 24 or mi > 59:
        return m.group(0)
    if _context_is_nepali(s, m.start(), m.end()):
        return ne_cardinal(h) + " बजे" + ("" if mi == 0 else "र " + ne_cardinal(mi) + " मिनेट")
    if mi == 0:
        return en_cardinal(h) + " o'clock"
    return en_cardinal(h) + " " + ("oh " + _ONES[mi] if mi < 10 else en_cardinal(mi))


def _one_number(s, m, last, out):
    """Append the spoken form of number match m to out; return True, or None."""
    if m.group(0).translate(_DEV2ASCII).count(".") > 1 or re.match(r"[.:/][0-9०-९]", s[m.end():m.end() + 2]):
        return None                       # 3.2.1, 1/2, 12:30:15 -- no safe reading
    tok = m.group(0)
    ne = _context_is_nepali(s, m.start(), m.end())
    raw = tok.translate(_DEV2ASCII)
    suffix = ""
    mo = re.match(r"^(.*?)(st|nd|rd|th)$", raw)
    if mo and mo.group(1).isdigit():
        raw, suffix = mo.group(1), "ord"
    digits = raw.replace(",", "")
    pre = s[last:m.start()]
    money = pre.endswith(("\x01", "\x02"))
    if money and "." in digits and len(digits.split(".", 1)[1]) == 2:
        a, b = (int(x) for x in digits.split(".", 1))
        usd = pre.endswith("\x02")
        if ne:
            big, small = ("डलर", "सेन्ट") if usd else ("रुपैयाँ", "पैसा")
            word = f"{ne_cardinal(a)} {big}" + (f" {ne_cardinal(b)} {small}" if b else "")
        else:
            big, small = ("dollars", "cents") if usd else ("rupees", "paisa")
            word = f"{en_cardinal(a)} {big}" + (f" and {en_cardinal(b)} {small}" if b else "")
        out.append(pre[:-1] + word)
        return True
    if "." in digits:
        a, b = digits.split(".", 1)
        word = ((ne_cardinal(int(a)) + " दशमलव " + " ".join(_NE_ONES[int(c)] for c in b))
                if ne else
                (en_cardinal(int(a)) + " point " + " ".join(_ONES[int(c)] for c in b)))
    elif len(digits) >= 6 or (digits.startswith("0") and len(digits) > 1):
        word = ne_digits(digits) if ne else en_digits(digits)
    elif suffix == "ord" and not ne:
        word = en_ordinal(int(digits))
    else:
        word = ne_cardinal(int(digits)) if ne else en_cardinal(int(digits))
    unit = ""
    if pre.endswith("\x01"):
        pre, unit = pre[:-1], (" रुपैयाँ" if ne else " rupees")
    elif pre.endswith("\x02"):
        pre, unit = pre[:-1], (" डलर" if ne else " dollars")
    out.append(pre + word + unit)
    return True


# ---- public API -------------------------------------------------------------------------
def normalize(text: str) -> str:
    """Raw text -> model letters. Same function for training data and live requests."""
    s = unicodedata.normalize("NFC", text or "").translate(_DROP)
    for a, b in _FOLD.items():
        s = s.replace(a, b)
    s = _verbalise_numbers(s)
    s = s.replace("%", " percent").replace("&", " and ")
    s = "".join(ch if ch in ALLOWED else (" " if ch.isspace() else "") for ch in s)
    s = re.sub(r"\s+", " ", s).strip()
    s = re.sub(r"\s+([.,?!;:…])", r"\1", s)                 # no space before punctuation
    s = re.sub(r"([.?!])\1+", r"\1", s)
    return s


def is_clean(raw: str, normalized: str) -> bool:
    """True if normalising dropped nothing but whitespace and known foldings --
    i.e. the sentence can be trained on without its text silently differing from
    what the audio says."""
    s = unicodedata.normalize("NFC", raw or "").translate(_DROP)
    for a, b in _FOLD.items():
        s = s.replace(a, b)
    s = _verbalise_numbers(s).replace("%", " percent").replace("&", " and ")
    return all(ch in ALLOWED or ch.isspace() for ch in s) and bool(normalized)


def to_ids(normalized: str) -> list[int]:
    return [STOI[BOS]] + [STOI[c] for c in normalized] + [STOI[EOS]]


if __name__ == "__main__":
    print(f"VOCAB {len(VOCAB)} symbols: {len(PUNCT)} punct, {len(LATIN)} Latin, "
          f"{len(DEVANAGARI)} Devanagari, 4 internal/space")
    for t in ["तपाईंको balance Rs. 250 छ।",
              "Your balance is Rs 1,250.50, valid until the 15th.",
              "Please call 9851005996 or 015574574.",
              "मेरो नम्बर ९८५१००५९९६ हो, र म ५ बजे आउँछु।",
              "रु ५०० को रिचार्ज गर्नुहोस्",
              "A & B give 20% off… ‘really’?",
              "emoji 😀 and © symbols are dropped"]:
        n = normalize(t)
        print(f"{'clean' if is_clean(t, n) else 'DIRTY'}  {t!r}\n       -> {n!r}")