jayron 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jayron/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ # Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
2
+ # Free for non-commercial use only; modification and redistribution are not allowed.
3
+ # Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
4
+
5
+ """jayron — tools for Uzbek speech recognition."""
6
+
7
+ __version__ = "0.1.0"
@@ -0,0 +1,14 @@
1
+ # Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
2
+ # Free for non-commercial use only; modification and redistribution are not allowed.
3
+ # Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
4
+
5
+ """Text normalizers.
6
+
7
+ from jayron.normalizers import uz_normilizer, converter
8
+ uz_normilizer("2024-yil 5 ta kitob") # 'ikki ming yigirma toʻrtinchi yil beshta kitob'
9
+ converter("Bog'chaga bordingmi?") # 'Boğçaga bordingmi?' (new 2026 alphabet)
10
+ """
11
+
12
+ from .uz import AlphabetConverter, UzbekNormalizer, converter, uz_normalizer, uz_normilizer
13
+
14
+ __all__ = ["AlphabetConverter", "UzbekNormalizer", "converter", "uz_normalizer", "uz_normilizer"]
@@ -0,0 +1,28 @@
1
+ # Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
2
+ # Free for non-commercial use only; modification and redistribution are not allowed.
3
+ # Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
4
+
5
+ """Uzbek text normalization for ASR."""
6
+
7
+ from .converter import AlphabetConverter, converter, to_new, to_old
8
+ from .normalizer import OKINA, UzbekNormalizer, expand_numbers
9
+ from .numbers import number_to_ordinal, number_to_words
10
+ from .transliterate import cyrillic_to_latin
11
+
12
+ uz_normalizer = UzbekNormalizer()
13
+ uz_normilizer = uz_normalizer # same object, under the spelling used in the project
14
+
15
+ __all__ = [
16
+ "OKINA",
17
+ "AlphabetConverter",
18
+ "UzbekNormalizer",
19
+ "converter",
20
+ "cyrillic_to_latin",
21
+ "to_new",
22
+ "to_old",
23
+ "expand_numbers",
24
+ "number_to_ordinal",
25
+ "number_to_words",
26
+ "uz_normalizer",
27
+ "uz_normilizer",
28
+ ]
@@ -0,0 +1,75 @@
1
+ # Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
2
+ # Free for non-commercial use only; modification and redistribution are not allowed.
3
+ # Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
4
+
5
+ """Command line: normalize text, text files, or JSONL manifests.
6
+
7
+ python -m jayron.normalizers.uz "2024-yil 5 ta kitob"
8
+ python -m jayron.normalizers.uz -i corpus.txt -o corpus.norm.txt
9
+ python -m jayron.normalizers.uz -i train.jsonl -o train.norm.jsonl --jsonl --field text
10
+ cat corpus.txt | python -m jayron.normalizers.uz
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from typing import List, Optional
19
+
20
+ from .normalizer import OKINA, UzbekNormalizer
21
+
22
+
23
+ def main(argv: Optional[List[str]] = None) -> int:
24
+ p = argparse.ArgumentParser(prog="jayron-uz-norm", description="Uzbek text normalizer for ASR")
25
+ p.add_argument("text", nargs="*", help="text to normalize (otherwise reads --input or stdin)")
26
+ p.add_argument("-i", "--input", help="input file (one sentence per line, or JSONL with --jsonl)")
27
+ p.add_argument("-o", "--output", help="output file (default: stdout)")
28
+ p.add_argument("--jsonl", action="store_true", help="input is JSON lines (e.g. a NeMo manifest)")
29
+ p.add_argument("--field", default="text", help="JSONL field to normalize (default: text)")
30
+ p.add_argument("--out-field", help="write the result to this field instead of overwriting --field")
31
+ p.add_argument("--alphabet", choices=("old", "new"), default="old",
32
+ help="old: oʻ gʻ sh ch (default); new: ö ğ ş ç (2026 reform)")
33
+ p.add_argument("--apostrophe", default=OKINA, help="character for oʻ/gʻ/tutuq (default: ʻ U+02BB)")
34
+ p.add_argument("--keep-case", action="store_true")
35
+ p.add_argument("--keep-punctuation", action="store_true")
36
+ p.add_argument("--no-transliterate", action="store_true", help="leave Cyrillic text as is")
37
+ p.add_argument("--no-numbers", action="store_true", help="leave digits as is")
38
+ args = p.parse_args(argv)
39
+
40
+ norm = UzbekNormalizer(
41
+ alphabet=args.alphabet,
42
+ apostrophe=args.apostrophe,
43
+ lowercase=not args.keep_case,
44
+ remove_punctuation=not args.keep_punctuation,
45
+ transliterate=not args.no_transliterate,
46
+ expand_numbers=not args.no_numbers,
47
+ )
48
+
49
+ if args.text:
50
+ print(norm(" ".join(args.text)))
51
+ return 0
52
+
53
+ src = open(args.input, encoding="utf-8") if args.input else sys.stdin
54
+ dst = open(args.output, "w", encoding="utf-8") if args.output else sys.stdout
55
+ try:
56
+ for line in src:
57
+ line = line.rstrip("\n")
58
+ if args.jsonl:
59
+ if not line.strip():
60
+ continue
61
+ record = json.loads(line)
62
+ record[args.out_field or args.field] = norm(record.get(args.field) or "")
63
+ dst.write(json.dumps(record, ensure_ascii=False) + "\n")
64
+ else:
65
+ dst.write(norm(line) + "\n")
66
+ finally:
67
+ if src is not sys.stdin:
68
+ src.close()
69
+ if dst is not sys.stdout:
70
+ dst.close()
71
+ return 0
72
+
73
+
74
+ if __name__ == "__main__":
75
+ sys.exit(main())
@@ -0,0 +1,139 @@
1
+ # Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
2
+ # Free for non-commercial use only; modification and redistribution are not allowed.
3
+ # Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
4
+
5
+ """Convert Uzbek text between the 1995 Latin alphabet and the 2026 reform.
6
+
7
+ The reform (law signed 21 Oct 2026, in force from 22 Apr 2027) replaces four
8
+ letters and drops Ng as a separate letter:
9
+
10
+ Oʻ -> Ö Gʻ -> Ğ Sh -> Ş Ch -> Ç (ng stays written as n + g)
11
+
12
+ The apostrophe (tutuq belgisi, as in maʼno) is kept, and so is the s'h spelling
13
+ of a separate s + h (Is'hoq stays Is'hoq).
14
+
15
+ >>> from jayron.normalizers import converter
16
+ >>> converter("Bog'chaga bordingmi?")
17
+ 'Boğçaga bordingmi?'
18
+ >>> converter.to_old("Boğçaga bordingmi?")
19
+ 'Bogʻchaga bordingmi?'
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import unicodedata
26
+
27
+ from .numbers import OKINA
28
+ from .transliterate import cyrillic_to_latin
29
+
30
+ __all__ = ["APOSTROPHES", "AlphabetConverter", "NEW_LETTERS", "converter", "to_new", "to_old"]
31
+
32
+ # ' ` ´ ʹ ʻ ʼ ʽ ‘ ’ ‛ ′ — all the ways people type the oʻ / gʻ mark
33
+ APOSTROPHES = "['`´ʹʻʼʽ‘’‛′]"
34
+ NEW_LETTERS = "öğşçÖĞŞÇ"
35
+
36
+ _OG = re.compile("([oOgG])" + APOSTROPHES)
37
+ _DIGRAPH = re.compile("([sScC])[hH]")
38
+ _TO_NEW = {"o": "ö", "O": "Ö", "g": "ğ", "G": "Ğ", "s": "ş", "S": "Ş", "c": "ç", "C": "Ç"}
39
+
40
+ # New letters, plus look-alikes from drafts and other keyboards (ō from the 2021 draft, ḡ, ș, ǧ ...).
41
+ _OLD_OG = {"ö": "o", "ō": "o", "ğ": "g", "ḡ": "g", "ǵ": "g", "ǧ": "g"}
42
+ _OLD_OG.update({k.upper(): v.upper() for k, v in list(_OLD_OG.items())})
43
+ _OLD_DIGRAPH = {"ş": "s", "ș": "s", "ç": "c"}
44
+ _OLD_DIGRAPH.update({k.upper(): v.upper() for k, v in list(_OLD_DIGRAPH.items())})
45
+ _NEW_LETTER = re.compile("[" + "".join(_OLD_OG) + "".join(_OLD_DIGRAPH) + "]")
46
+
47
+ # Links and e-mail addresses are left untouched.
48
+ _PROTECTED = re.compile(r"(?:https?://|www\.)\S+|[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
49
+
50
+
51
+ def _letters_to_old(text: str, apostrophe: str = OKINA) -> str:
52
+ """ö -> oʻ, ğ -> gʻ, ş -> sh, ç -> ch (also accepts ō, ḡ, ș, ...)."""
53
+ if text.isascii():
54
+ return text
55
+
56
+ def repl(m: "re.Match[str]") -> str:
57
+ ch = m.group()
58
+ if ch in _OLD_OG:
59
+ return _OLD_OG[ch] + apostrophe
60
+ base = _OLD_DIGRAPH[ch]
61
+ if base.islower():
62
+ return base + "h"
63
+ i, s = m.start(), m.string
64
+ prev = s[i - 1] if i else ""
65
+ nxt = s[i + 1] if i + 1 < len(s) else ""
66
+ all_caps = nxt.isupper() or (prev.isupper() and not nxt.isalpha())
67
+ return base + ("H" if all_caps else "h")
68
+
69
+ return _NEW_LETTER.sub(repl, text)
70
+
71
+
72
+ def _letters_to_new(text: str) -> str:
73
+ text = _OG.sub(lambda m: _TO_NEW[m.group(1)], text)
74
+ return _DIGRAPH.sub(lambda m: _TO_NEW[m.group(1)], text)
75
+
76
+
77
+ def _outside_links(text: str, fn) -> str:
78
+ parts, last = [], 0
79
+ for m in _PROTECTED.finditer(text):
80
+ parts.append(fn(text[last:m.start()]))
81
+ parts.append(m.group())
82
+ last = m.end()
83
+ parts.append(fn(text[last:]))
84
+ return "".join(parts)
85
+
86
+
87
+ def to_new(text: str, *, transliterate: bool = True) -> str:
88
+ """Convert to the new alphabet: "Bog'chaga" -> "Boğçaga".
89
+
90
+ Accepts the old Latin alphabet (any apostrophe variant), Cyrillic (when
91
+ ``transliterate`` is on), or text already partly in the new alphabet.
92
+ Everything else (case, punctuation, digits, ng, tutuq) is kept as is.
93
+ """
94
+ if not text:
95
+ return text
96
+ text = unicodedata.normalize("NFC", text)
97
+ if transliterate:
98
+ text = cyrillic_to_latin(text)
99
+ return _outside_links(text, lambda part: _letters_to_new(_letters_to_old(part)))
100
+
101
+
102
+ def to_old(text: str, *, apostrophe: str = OKINA, transliterate: bool = True) -> str:
103
+ """Convert to the 1995 alphabet: "Boğçaga" -> "Bogʻchaga" (``apostrophe`` marks oʻ/gʻ)."""
104
+ if not text:
105
+ return text
106
+ text = unicodedata.normalize("NFC", text)
107
+ if transliterate:
108
+ text = cyrillic_to_latin(text)
109
+ return _letters_to_old(text, apostrophe)
110
+
111
+
112
+ class AlphabetConverter:
113
+ """Old (1995) <-> new (2026) Uzbek Latin alphabet. Calling it converts to the new one.
114
+
115
+ Args:
116
+ transliterate: also accept Uzbek Cyrillic input.
117
+ apostrophe: character for oʻ/gʻ when converting back to the old alphabet.
118
+ """
119
+
120
+ def __init__(self, *, transliterate: bool = True, apostrophe: str = OKINA) -> None:
121
+ self.transliterate = transliterate
122
+ self.apostrophe = apostrophe
123
+
124
+ def to_new(self, text: str) -> str:
125
+ return to_new(text, transliterate=self.transliterate)
126
+
127
+ def to_old(self, text: str) -> str:
128
+ return to_old(text, apostrophe=self.apostrophe, transliterate=self.transliterate)
129
+
130
+ def __call__(self, text):
131
+ if isinstance(text, str):
132
+ return self.to_new(text)
133
+ return [self.to_new(t) for t in text]
134
+
135
+ def __repr__(self) -> str:
136
+ return f"AlphabetConverter(transliterate={self.transliterate!r}, apostrophe={self.apostrophe!r})"
137
+
138
+
139
+ converter = AlphabetConverter()
@@ -0,0 +1,484 @@
1
+ # Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
2
+ # Free for non-commercial use only; modification and redistribution are not allowed.
3
+ # Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
4
+
5
+ """Uzbek text normalizer for speech recognition (ASR).
6
+
7
+ Turns written Uzbek into the form a speaker would say it: Latin script,
8
+ numbers/dates/times/units spelled out, one apostrophe character, no
9
+ punctuation, lowercase. Every step can be switched off.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import unicodedata
16
+ from typing import Iterable, List, Optional, Sequence, Tuple, Union
17
+
18
+ from .numbers import (
19
+ MONTHS,
20
+ OKINA,
21
+ attach_suffix,
22
+ decimal_to_words,
23
+ fraction_to_words,
24
+ number_to_ordinal,
25
+ read_integer,
26
+ roman_to_int,
27
+ )
28
+ from .converter import APOSTROPHES, _letters_to_old, to_new
29
+ from .transliterate import cyrillic_to_latin, fix_mixed_script
30
+
31
+ __all__ = ["UzbekNormalizer", "expand_numbers", "OKINA"]
32
+
33
+
34
+ def _ok(text: str) -> str:
35
+ return text.replace("'", OKINA)
36
+
37
+
38
+ # --------------------------------------------------------------------------- unicode clean-up
39
+
40
+ _INVISIBLE = re.compile("[­​-‍⁠]")
41
+ _SPACES = re.compile("[\t\n\r\f\v   - 

   ]")
42
+ _COMPAT = re.compile("[ff-st!-~]") # ligatures, full-width ASCII
43
+ _NON_ASCII_DIGIT = re.compile(r"(?![0-9])\d")
44
+ _HYPHENS = re.compile("[‐-‒−﹣]")
45
+ _DASH_IN_RANGE = re.compile(r"(?<=\d) ?[–—] ?(?=\d)")
46
+ _DASH_AS_MINUS = re.compile(r"(?<!\S)[–—](?=\d)")
47
+ _ANNOTATION = re.compile(r"\[[^\[\]]{1,50}\]|</?[A-Za-z_][\w-]{0,30}>")
48
+ _SUPERSCRIPTS = str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹", "0123456789")
49
+
50
+
51
+ def _pre_clean(text: str) -> str:
52
+ text = unicodedata.normalize("NFC", text)
53
+ text = _INVISIBLE.sub("", text)
54
+ text = _SPACES.sub(" ", text)
55
+ if not text.isascii():
56
+ text = _COMPAT.sub(lambda m: unicodedata.normalize("NFKC", m.group()), text)
57
+ text = _NON_ASCII_DIGIT.sub(lambda m: str(unicodedata.decimal(m.group())), text)
58
+ text = _HYPHENS.sub("-", text)
59
+ text = _DASH_IN_RANGE.sub("-", text)
60
+ text = _DASH_AS_MINUS.sub("-", text)
61
+ return text
62
+
63
+
64
+ # --------------------------------------------------------------------------- apostrophes
65
+
66
+ _APOS = APOSTROPHES
67
+ _OG_APOS = re.compile("([oOgG])" + _APOS)
68
+ _INNER_APOS = re.compile(r"(?<=[^\W\d_])" + _APOS + r"(?=[^\W\d_])")
69
+ _STRAY_APOS = re.compile("(?!" + OKINA + ")" + _APOS)
70
+
71
+
72
+ def _canonical_apostrophes(text: str) -> str:
73
+ """oʻ/gʻ and the tutuq belgisi all become ʻ; anything else apostrophe-like is a quote mark."""
74
+ text = _OG_APOS.sub(lambda m: m.group(1) + OKINA, text)
75
+ text = _INNER_APOS.sub(OKINA, text)
76
+ return _STRAY_APOS.sub("'", text)
77
+
78
+
79
+ # --------------------------------------------------------------------------- abbreviations & symbols
80
+
81
+ _NOT_AFTER_LETTER = r"(?<![^\W\d_])"
82
+ _ABBREVIATIONS = [
83
+ (re.compile(r"(?<!\w)va\s+h\.\s?k\.?(?!\w)"), "va hokazo"),
84
+ (re.compile(r"(?<!\w)h\.\s?k\.?(?!\w)"), "hokazo"),
85
+ (re.compile(r"(?<!\w)va\s+b\.(?!\w)"), "va boshqalar"),
86
+ (re.compile(_NOT_AFTER_LETTER + r"mlrd\.?(?!\w)"), "milliard"),
87
+ (re.compile(_NOT_AFTER_LETTER + r"mln\.?(?!\w)"), "million"),
88
+ (re.compile(_NOT_AFTER_LETTER + r"trln\.?(?!\w)"), "trillion"),
89
+ (re.compile(r"(?<!\w)prof\.(?!\w)"), "professor"),
90
+ (re.compile(r"(?<!\w)dots\.(?!\w)"), "dotsent"),
91
+ (re.compile(r"(?<!\w)akad\.(?!\w)"), "akademik"),
92
+ (re.compile(r"(?<!\w)mas\.(?!\w)"), "masalan"),
93
+ (re.compile(_ok(r"(?<!\w)ko'ch\.(?!\w)")), _ok("ko'chasi")),
94
+ # "Toshkent sh.", "Samarqand v.", "Chilonzor t."
95
+ (re.compile(r"(?<!\w)([A-Z]\w+)\s+sh\.(?!\w)"), r"\1 shahri"),
96
+ (re.compile(r"(?<!\w)([A-Z]\w+)\s+v\.(?!\w)"), r"\1 viloyati"),
97
+ (re.compile(r"(?<!\w)([A-Z]\w+)\s+t\.(?!\w)"), r"\1 tumani"),
98
+ ]
99
+
100
+ _SYMBOLS = {
101
+ "%": "foiz", "‰": "promille", "№": "raqam", "&": "va", "§": "paragraf", "°": "gradus",
102
+ "$": "dollar", "€": "yevro", "£": "funt sterling", "₽": "rubl", "¥": "iyena", "₸": "tenge",
103
+ }
104
+ _SYMBOL_RE = re.compile("[" + re.escape("".join(_SYMBOLS)) + "]")
105
+
106
+
107
+ def _expand_abbreviations(text: str) -> str:
108
+ for pattern, replacement in _ABBREVIATIONS:
109
+ text = pattern.sub(replacement, text)
110
+ return text
111
+
112
+
113
+ def _expand_symbols(text: str) -> str:
114
+ return _SYMBOL_RE.sub(lambda m: " " + _SYMBOLS[m.group()] + " ", text)
115
+
116
+
117
+ # --------------------------------------------------------------------------- number helpers
118
+
119
+ # A number as written: 25 | 1 000 000 | 1,000,000 | 1.000.000 | 3,5 | 2.75
120
+ _NUM = (
121
+ r"(?<!\d)(?:"
122
+ r"[1-9]\d{0,2}(?:[ ,]\d{3})+(?!\d)"
123
+ r"|[1-9]\d{0,2}(?:\.\d{3}){2,}(?!\d)"
124
+ r"|\d+"
125
+ r")(?:[.,]\d+(?![.,]?\d))?"
126
+ )
127
+ _GROUPED = re.compile(r"([1-9]\d{0,2}(?:([ ,.])\d{3})+)(?:[.,](\d+))?")
128
+ _PLAIN = re.compile(r"(\d+)(?:[.,](\d+))?")
129
+
130
+
131
+ def _split_number(token: str) -> Tuple[str, Optional[str]]:
132
+ """'1 000,5' -> ('1000', '5'); '3.5' -> ('3', '5'); '1,500' -> ('1500', None)."""
133
+ m = _GROUPED.fullmatch(token)
134
+ # a single dot group ("1.500") is a decimal, not thousands
135
+ if m and (m.group(2) != "." or m.group(1).count(".") >= 2):
136
+ return re.sub(r"\D", "", m.group(1)), m.group(3)
137
+ m = _PLAIN.fullmatch(token)
138
+ if m:
139
+ return m.group(1), m.group(2)
140
+ return re.sub(r"\D", "", token), None
141
+
142
+
143
+ def _say(token: str, ordinal: bool = False) -> str:
144
+ integer, fraction = _split_number(token)
145
+ if fraction is not None:
146
+ return decimal_to_words(integer, fraction)
147
+ if ordinal:
148
+ return number_to_ordinal(int(integer))
149
+ return read_integer(integer)
150
+
151
+
152
+ def _say_range(first: Optional[str], second: str, ordinal: bool = False) -> str:
153
+ words = _say(second, ordinal)
154
+ return _say(first, ordinal) + " " + words if first else words
155
+
156
+
157
+ _CASE = r"(?:dan|da|gacha|qacha|kacha|ga|qa|ka|ning|ni|dagi|dek|day|cha)"
158
+ # glued to a cardinal: 5ta, 5-ta, 10dan, 40ga, 100lab, 5tasi
159
+ _ATTACH = re.compile(rf"(?:ta|tasi|lar|lari|lik|lab)?{_CASE}?")
160
+ # written as a separate word but still a suffix: "5 ta", "10 dan"
161
+ _SPACED_ATTACH = re.compile(rf"ta(?:si)?{_CASE}?|{_CASE}")
162
+ # ordinal markers: 5-chi, 2-inchi, 1-nchisi, may oyining 5-si
163
+ _ORD_MARKER = re.compile(rf"(?:i?n)?chi((?:lar)?(?:i|si)?{_CASE}?)|(si{_CASE}?)")
164
+ # after a hyphen these words take a cardinal: 3-xonali, 100-yillik, 30-yilligi
165
+ _CARDINAL_HINT = re.compile(r"(?:li|lik|liq)$|lig")
166
+ _MONTH = re.compile(r"(?:%s)(?:da|dan|ga|gacha|ning|ni|dagi|gi)?" % "|".join(MONTHS[1:]))
167
+
168
+
169
+ def _verbalize(token: str, sep: Optional[str], word: Optional[str]) -> Tuple[str, bool]:
170
+ """Spell a number given the word that follows it. Returns (text, became_ordinal)."""
171
+ integer, fraction = _split_number(token)
172
+ if word is None:
173
+ return _say(token), False
174
+ low = word.lower()
175
+ if fraction is None:
176
+ n = int(integer)
177
+ if sep in ("", "-"):
178
+ m = _ORD_MARKER.fullmatch(low)
179
+ if m:
180
+ rest = m.group(1) if m.group(1) is not None else m.group(2)
181
+ return number_to_ordinal(n) + word[len(word) - len(rest):], True
182
+ if sep == " " and (
183
+ (1 <= n <= 31 and _MONTH.fullmatch(low)) # "15 mart"
184
+ or (1000 <= n <= 2999 and low.startswith("yil") and not _CARDINAL_HINT.search(low)) # "2024 yil"
185
+ ):
186
+ return number_to_ordinal(n) + " " + word, True
187
+ cardinal = _say(token)
188
+ if (sep in ("", "-") and _ATTACH.fullmatch(low)) or (sep == " " and _SPACED_ATTACH.fullmatch(low)):
189
+ return attach_suffix(cardinal, word), False
190
+ if sep == "-" and fraction is None and not _CARDINAL_HINT.search(low):
191
+ return number_to_ordinal(int(integer)) + " " + word, True # 2024-yil, 5-sinf, 1-may
192
+ return cardinal + " " + word, False
193
+
194
+
195
+ # --------------------------------------------------------------------------- number passes
196
+
197
+ _PHONES = (
198
+ re.compile(r"(?<![\w+])\+?(998)[ -]?\(?(\d{2})\)?[ -]?(\d{3})[ -]?(\d{2})[ -]?(\d{2})(?!\d)"),
199
+ re.compile(r"(?<![\w+])\+(\d{1,3})[ -]?\(?(\d{2,4})\)?[ -]?(\d{2,4})[ -]?(\d{2})[ -]?(\d{2})(?!\d)"),
200
+ re.compile(r"(?<![\w+])\(?(\d{2})\)?[ -](\d{3})[ -](\d{2})[ -](\d{2})(?!\d)"),
201
+ )
202
+ _DATE_DMY = re.compile(
203
+ r"(?<![\d.,])(0?[1-9]|[12]\d|3[01])([./-])(0?[1-9]|1[0-2])\2((?:1[6-9]|20|21)\d{2})(?!\d)"
204
+ r"(?: ?-? ?(y\.|yil\w*))?"
205
+ )
206
+ _DATE_YMD = re.compile(r"(?<![\d.,-])((?:1[6-9]|20|21)\d{2})([-./])(0[1-9]|1[0-2])\2(0[1-9]|[12]\d|3[01])(?!\d)")
207
+ _TIME_SUFFIX = r"(?:[- ](?=(?:da|dan|ga|gacha|cha|ni|ning|dagi)(?!\w)))?"
208
+ _TIME = re.compile(r"(?<![\d:.,])([01]?\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?(?![\d:])" + _TIME_SUFFIX)
209
+ _SOAT_TIME = re.compile(r"(?<!\w)([Ss]oat\s+)([01]?\d|2[0-3])\.([0-5]\d)(?![\d.])" + _TIME_SUFFIX)
210
+ _ROMAN_RE = re.compile(
211
+ r"(?<![\w-])(?=[MDCLXVI])(M{0,3}(?:CM|CD|D?C{0,3})(?:XC|XL|L?X{0,3})(?:IX|IV|V?I{0,3}))(?: ?- ?| +)"
212
+ + _ok(r"(?=(?i:asr|bob|jild|qism|kurs|chorak|bosqich|modda|bo'lim|sinf|toifa|guruh|qurultoy))")
213
+ )
214
+ _VULGAR = {
215
+ "½": (1, 2), "⅓": (1, 3), "⅔": (2, 3), "¼": (1, 4), "¾": (3, 4), "⅕": (1, 5), "⅖": (2, 5),
216
+ "⅗": (3, 5), "⅘": (4, 5), "⅙": (1, 6), "⅚": (5, 6), "⅛": (1, 8), "⅜": (3, 8), "⅝": (5, 8), "⅞": (7, 8),
217
+ }
218
+ _VULGAR_RE = re.compile(r"(?<![\w.,])(?:(\d+) ?)?([" + "".join(_VULGAR) + "])")
219
+ _FRACTION = re.compile(r"(?<![\w.,/])(\d{1,3})/(\d{1,3})(?![\d/])")
220
+ _PLUS = re.compile(r"(?<=\d) ?\+ ?(?=\d)")
221
+ _EQUALS = re.compile(r"(?<=\d) ?= ?(?=[-+]?\d)")
222
+ _TIMES = re.compile(r"(?<=\d) ?[x×] ?(?=\d)")
223
+ _MINUS_SIGN = re.compile(r"(?<![\w.,])-(?=\d)")
224
+ _PLUS_SIGN = re.compile(r"(?<![\w.,])\+(?=\d)")
225
+
226
+ _RANGE_PREFIX = rf"(?:({_NUM})-)?" # "5-10%" -> both numbers belong to the unit
227
+ # lets a unit word take a following suffix: "25%-ga", "25% ga" -> "foizga"
228
+ _GLUE = rf"(?:-(?=[^\W\d_])| (?={_CASE}(?!\w)))?"
229
+ _SCALE = r"(?: ?(ming|million|milliard|trillion)(?!\w))?"
230
+ _CURRENCY_SYMBOLS = {"$": "dollar", "€": "yevro", "£": "funt sterling", "₽": "rubl", "¥": "iyena", "₸": "tenge"}
231
+ _CURRENCY_CODES = {
232
+ "USD": "dollar", "EUR": "yevro", "RUB": "rubl", "UZS": _ok("so'm"), "KZT": "tenge",
233
+ "GBP": "funt sterling", "CNY": "yuan",
234
+ }
235
+ _SYM_CLASS = "[" + re.escape("".join(_CURRENCY_SYMBOLS)) + "]"
236
+ _CURRENCY_BEFORE = re.compile(rf"({_SYM_CLASS}) ?({_NUM})(?:-({_NUM}))?{_SCALE}")
237
+ _CURRENCY_AFTER = re.compile(rf"{_RANGE_PREFIX}({_NUM}){_SCALE} ?({_SYM_CLASS}){_GLUE}")
238
+ _CURRENCY_CODE = re.compile(rf"{_RANGE_PREFIX}({_NUM}){_SCALE} ?({'|'.join(_CURRENCY_CODES)})(?![A-Za-z]){_GLUE}")
239
+ _PERCENT = re.compile(rf"{_RANGE_PREFIX}({_NUM}) ?([%‰]){_GLUE}")
240
+ _DEGREES = re.compile(rf"{_RANGE_PREFIX}({_NUM}) ?° ?([CSF](?![A-Za-z]))?{_GLUE}")
241
+
242
+ _UNITS = {
243
+ "km²": "kvadrat kilometr", "m²": "kvadrat metr", "sm²": "kvadrat santimetr", "mm²": "kvadrat millimetr",
244
+ "km³": "kub kilometr", "m³": "kub metr", "sm³": "kub santimetr",
245
+ "km": "kilometr", "m": "metr", "sm": "santimetr", "cm": "santimetr", "mm": "millimetr",
246
+ "kg": "kilogramm", "g": "gramm", "gr": "gramm", "mg": "milligramm", "t": "tonna",
247
+ "l": "litr", "ml": "millilitr",
248
+ "kVt": "kilovatt", "MVt": "megavatt", "Vt": "vatt",
249
+ "KB": "kilobayt", "MB": "megabayt", "GB": "gigabayt", "TB": "terabayt",
250
+ "min": "daqiqa", "sek": "soniya",
251
+ }
252
+ _UNIT_RE = re.compile(
253
+ rf"{_RANGE_PREFIX}({_NUM}) ?({'|'.join(sorted(map(re.escape, _UNITS), key=len, reverse=True))})(?!\w){_GLUE}"
254
+ )
255
+ _YEAR_ABBR = re.compile(r"(?<![\w.,])(?:(\d{3,4})-)?(\d{3,4}) ?-? ?(yy|y)\.(?!\w)")
256
+ _RANGE = re.compile(rf"(?<![\w.,])({_NUM})-({_NUM})(?:(-| ?)([^\W\d_]\w*))?")
257
+ _NUMBER_WORD = re.compile(rf"(?<![\w.,])({_NUM})(?:(-| ?)([^\W\d_]\w*))?")
258
+ _ANY_DIGITS = re.compile(r"\d+")
259
+
260
+
261
+ def _date_dmy(m: "re.Match[str]") -> str:
262
+ day, _, month, year, year_word = m.groups()
263
+ year_word = "yil" if year_word in (None, "y.") else year_word
264
+ return f"{number_to_ordinal(int(day))} {MONTHS[int(month)]} {number_to_ordinal(int(year))} {year_word}"
265
+
266
+
267
+ def _date_ymd(m: "re.Match[str]") -> str:
268
+ year, _, month, day = m.groups()
269
+ return f"{number_to_ordinal(int(year))} yil {number_to_ordinal(int(day))} {MONTHS[int(month)]}"
270
+
271
+
272
+ def _time(hours: str, minutes: str, seconds: Optional[str] = None) -> str:
273
+ parts = [read_integer(hours.lstrip("0") or "0"), read_integer(minutes)]
274
+ if seconds:
275
+ parts.append(read_integer(seconds))
276
+ return " ".join(parts)
277
+
278
+
279
+ def _vulgar(m: "re.Match[str]") -> str:
280
+ numerator, denominator = _VULGAR[m.group(2)]
281
+ words = fraction_to_words(numerator, denominator)
282
+ return (_say(m.group(1)) + " butun " + words) if m.group(1) else words
283
+
284
+
285
+ def _fraction(m: "re.Match[str]") -> str:
286
+ numerator, denominator = int(m.group(1)), int(m.group(2))
287
+ if 0 < numerator < denominator:
288
+ return fraction_to_words(numerator, denominator)
289
+ return m.group()
290
+
291
+
292
+ def _with_scale(text: str, scale: Optional[str]) -> str:
293
+ return f"{text} {scale}" if scale else text
294
+
295
+
296
+ def _range(m: "re.Match[str]") -> str:
297
+ first, second, sep, word = m.groups()
298
+ second_text, ordinal = _verbalize(second, sep, word)
299
+ integer, fraction = _split_number(first)
300
+ first_text = number_to_ordinal(int(integer)) if ordinal and fraction is None else _say(first)
301
+ return first_text + " " + second_text
302
+
303
+
304
+ def expand_numbers(text: str) -> str:
305
+ """Spell out every number in ``text`` (expects canonical apostrophes)."""
306
+ text = _ROMAN_RE.sub(lambda m: number_to_ordinal(roman_to_int(m.group(1))) + " ", text)
307
+ if not any(c.isdigit() or c in _VULGAR for c in text):
308
+ return text
309
+ for pattern in _PHONES:
310
+ text = pattern.sub(lambda m: " ".join(read_integer(g) for g in m.groups()), text)
311
+ text = _DATE_DMY.sub(_date_dmy, text)
312
+ text = _DATE_YMD.sub(_date_ymd, text)
313
+ text = _TIME.sub(lambda m: _time(*m.groups()), text)
314
+ text = _SOAT_TIME.sub(lambda m: m.group(1) + _time(m.group(2), m.group(3)), text)
315
+ text = _VULGAR_RE.sub(_vulgar, text)
316
+ text = _FRACTION.sub(_fraction, text)
317
+ text = _PLUS.sub(" plyus ", text)
318
+ text = _EQUALS.sub(" teng ", text)
319
+ text = _TIMES.sub(" karra ", text)
320
+ text = _MINUS_SIGN.sub("minus ", text)
321
+ text = _PLUS_SIGN.sub("plyus ", text)
322
+ text = _CURRENCY_BEFORE.sub(
323
+ lambda m: _with_scale(_say_range(m.group(2), m.group(3)) if m.group(3) else _say(m.group(2)), m.group(4))
324
+ + " " + _CURRENCY_SYMBOLS[m.group(1)],
325
+ text,
326
+ )
327
+ text = _CURRENCY_AFTER.sub(
328
+ lambda m: _with_scale(_say_range(m.group(1), m.group(2)), m.group(3)) + " " + _CURRENCY_SYMBOLS[m.group(4)],
329
+ text,
330
+ )
331
+ text = _CURRENCY_CODE.sub(
332
+ lambda m: _with_scale(_say_range(m.group(1), m.group(2)), m.group(3)) + " " + _CURRENCY_CODES[m.group(4)],
333
+ text,
334
+ )
335
+ text = _PERCENT.sub(
336
+ lambda m: _say_range(m.group(1), m.group(2)) + (" foiz" if m.group(3) == "%" else " promille"), text
337
+ )
338
+ text = _DEGREES.sub(
339
+ lambda m: _say_range(m.group(1), m.group(2)) + " gradus" + (" farengeyt" if m.group(3) == "F" else ""),
340
+ text,
341
+ )
342
+ text = _UNIT_RE.sub(lambda m: _say_range(m.group(1), m.group(2)) + " " + _UNITS[m.group(3)], text)
343
+ text = _YEAR_ABBR.sub(
344
+ lambda m: _say_range(m.group(1), m.group(2), ordinal=True) + (" yillar" if m.group(3) == "yy" else " yil"),
345
+ text,
346
+ )
347
+ text = _RANGE.sub(_range, text)
348
+ text = _NUMBER_WORD.sub(lambda m: _verbalize(*m.groups())[0], text)
349
+ # anything left (digits glued to letters: "A5", superscripts) is read plainly
350
+ text = text.translate(_SUPERSCRIPTS)
351
+ return _ANY_DIGITS.sub(lambda m: " " + read_integer(m.group()) + " ", text)
352
+
353
+
354
+ # --------------------------------------------------------------------------- final clean-up
355
+
356
+ _LATIN_SPECIAL = str.maketrans({"ı": "i", "ø": "o", "Ø": "O", "ł": "l", "Ł": "L", "đ": "d", "Đ": "D", "ß": "ss"})
357
+
358
+
359
+ def _strip_latin_accents(text: str) -> str:
360
+ """é -> e, ö -> o, ç -> c. Cyrillic letters (й, ё) are left alone."""
361
+ if text.isascii():
362
+ return text
363
+ text = text.translate(_LATIN_SPECIAL)
364
+ out = []
365
+ after_latin = False
366
+ for ch in unicodedata.normalize("NFD", text):
367
+ if unicodedata.combining(ch):
368
+ if after_latin:
369
+ continue
370
+ else:
371
+ after_latin = ch.isascii() and ch.isalpha()
372
+ out.append(ch)
373
+ return unicodedata.normalize("NFC", "".join(out))
374
+
375
+
376
+ _NON_WORD = re.compile(r"[^\w\s]|_")
377
+ _LONE_OKINA = re.compile(r"(?<![^\W\d_])" + OKINA)
378
+ _SPACE_BEFORE_PUNCT = re.compile(r" +([.,!?;:)\]»])")
379
+ _MULTISPACE = re.compile(r"\s+")
380
+
381
+ Rule = Tuple[Union[str, "re.Pattern[str]"], str]
382
+
383
+
384
+ class UzbekNormalizer:
385
+ """Normalize Uzbek text for ASR training and evaluation.
386
+
387
+ >>> norm = UzbekNormalizer()
388
+ >>> norm("2024-yil 15-mart kuni soat 14:30 da 1,5 mln so'm to'landi.")
389
+ 'ikki ming yigirma toʻrtinchi yil oʻn beshinchi mart kuni soat oʻn toʻrt oʻttizda bir butun oʻndan besh million soʻm toʻlandi'
390
+
391
+ Args:
392
+ transliterate: convert Cyrillic Uzbek to Latin.
393
+ expand_numbers: spell out numbers, dates, times, money, percent, units.
394
+ expand_abbreviations: expand abbreviations (mln, h.k., sh.) and symbols (%, №, &).
395
+ lowercase: lowercase the output.
396
+ remove_punctuation: drop punctuation and quote marks (word-internal apostrophes stay).
397
+ strip_accents: é -> e, ü -> u for foreign Latin letters.
398
+ drop_annotations: remove [noise], [1], <unk> style tags.
399
+ apostrophe: the single character used for oʻ, gʻ and the tutuq belgisi.
400
+ Default is ʻ (U+02BB); pass "'" for plain ASCII.
401
+ custom_rules: extra (regex, replacement) pairs applied first, for domain terms.
402
+ alphabet: "old" (1995: oʻ gʻ sh ch) or "new" (2026 reform: ö ğ ş ç).
403
+ Input in either alphabet is accepted.
404
+ """
405
+
406
+ def __init__(
407
+ self,
408
+ *,
409
+ transliterate: bool = True,
410
+ expand_numbers: bool = True,
411
+ expand_abbreviations: bool = True,
412
+ lowercase: bool = True,
413
+ remove_punctuation: bool = True,
414
+ strip_accents: bool = True,
415
+ drop_annotations: bool = True,
416
+ apostrophe: str = OKINA,
417
+ custom_rules: Sequence[Rule] = (),
418
+ alphabet: str = "old",
419
+ ) -> None:
420
+ if alphabet not in ("old", "new"):
421
+ raise ValueError(f'alphabet must be "old" or "new", got {alphabet!r}')
422
+ self.alphabet = alphabet
423
+ self.transliterate = transliterate
424
+ self.expand_numbers = expand_numbers
425
+ self.expand_abbreviations = expand_abbreviations
426
+ self.lowercase = lowercase
427
+ self.remove_punctuation = remove_punctuation
428
+ self.strip_accents = strip_accents
429
+ self.drop_annotations = drop_annotations
430
+ self.apostrophe = apostrophe
431
+ self.custom_rules = [(re.compile(p) if isinstance(p, str) else p, r) for p, r in custom_rules]
432
+
433
+ def normalize(self, text: str) -> str:
434
+ if not text:
435
+ return ""
436
+ text = _pre_clean(text)
437
+ for pattern, replacement in self.custom_rules:
438
+ text = pattern.sub(replacement, text)
439
+ if self.drop_annotations:
440
+ text = _ANNOTATION.sub(" ", text)
441
+ text = fix_mixed_script(text)
442
+ if self.transliterate:
443
+ text = cyrillic_to_latin(text)
444
+ text = _canonical_apostrophes(text)
445
+ # work internally in the old alphabet: ö ğ ş ç -> oʻ gʻ sh ch
446
+ text = _letters_to_old(text)
447
+ if self.expand_abbreviations:
448
+ text = _expand_abbreviations(text)
449
+ if self.expand_numbers:
450
+ text = expand_numbers(text)
451
+ if self.expand_abbreviations:
452
+ text = _expand_symbols(text)
453
+ if self.lowercase:
454
+ text = text.lower()
455
+ if self.strip_accents:
456
+ text = _strip_latin_accents(text)
457
+ if self.remove_punctuation:
458
+ text = _NON_WORD.sub(" ", text)
459
+ text = _LONE_OKINA.sub("", text)
460
+ text = _MULTISPACE.sub(" ", text).strip()
461
+ else:
462
+ text = _MULTISPACE.sub(" ", text).strip()
463
+ text = _SPACE_BEFORE_PUNCT.sub(r"\1", text)
464
+ if self.alphabet == "new":
465
+ text = to_new(text, transliterate=False)
466
+ if self.apostrophe != OKINA:
467
+ text = text.replace(OKINA, self.apostrophe)
468
+ return text
469
+
470
+ def normalize_batch(self, texts: Iterable[str]) -> List[str]:
471
+ return [self.normalize(t) for t in texts]
472
+
473
+ def __call__(self, text: Union[str, Iterable[str]]) -> Union[str, List[str]]:
474
+ if isinstance(text, str):
475
+ return self.normalize(text)
476
+ return self.normalize_batch(text)
477
+
478
+ def __repr__(self) -> str:
479
+ opts = ", ".join(
480
+ f"{k}={getattr(self, k)!r}"
481
+ for k in ("alphabet", "transliterate", "expand_numbers", "expand_abbreviations", "lowercase",
482
+ "remove_punctuation", "strip_accents", "drop_annotations", "apostrophe")
483
+ )
484
+ return f"UzbekNormalizer({opts})"
@@ -0,0 +1,133 @@
1
+ # Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
2
+ # Free for non-commercial use only; modification and redistribution are not allowed.
3
+ # Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
4
+
5
+ """Spell numbers as Uzbek (Latin script) words."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Tuple
10
+
11
+ OKINA = "ʻ" # ʻ (as in oʻ, gʻ)
12
+
13
+
14
+ def _ok(*words: str) -> Tuple[str, ...]:
15
+ """Write words with ASCII ' in the source, store them with the okina."""
16
+ return tuple(w.replace("'", OKINA) for w in words)
17
+
18
+
19
+ ONES = _ok("nol", "bir", "ikki", "uch", "to'rt", "besh", "olti", "yetti", "sakkiz", "to'qqiz")
20
+ TENS = _ok("", "o'n", "yigirma", "o'ttiz", "qirq", "ellik", "oltmish", "yetmish", "sakson", "to'qson")
21
+ MONTHS = (
22
+ "", "yanvar", "fevral", "mart", "aprel", "may", "iyun",
23
+ "iyul", "avgust", "sentabr", "oktabr", "noyabr", "dekabr",
24
+ )
25
+
26
+ _SCALES = ((10 ** 12, "trillion"), (10 ** 9, "milliard"), (10 ** 6, "million"), (10 ** 3, "ming"))
27
+ _LIMIT = 10 ** 15 # from here on, numbers are read digit by digit
28
+ _SCALE_WORDS = frozenset(("yuz", "ming", "million", "milliard", "trillion"))
29
+ _DECIMAL_PLACES = dict(enumerate(_ok("o'ndan", "yuzdan", "mingdan", "o'n mingdan", "yuz mingdan", "milliondan"), 1))
30
+ _VOWELS = frozenset("aeiou")
31
+ _ROMAN = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
32
+
33
+
34
+ def _below_thousand(n: int, *, bir_yuz: bool) -> str:
35
+ hundreds, rest = divmod(n, 100)
36
+ parts = []
37
+ if hundreds == 1:
38
+ parts.append("bir yuz" if bir_yuz else "yuz")
39
+ elif hundreds:
40
+ parts.append(ONES[hundreds] + " yuz")
41
+ tens, ones = divmod(rest, 10)
42
+ if tens:
43
+ parts.append(TENS[tens])
44
+ if ones:
45
+ parts.append(ONES[ones])
46
+ return " ".join(parts)
47
+
48
+
49
+ def digits_to_words(digits: str) -> str:
50
+ """'007' -> 'nol nol yetti'."""
51
+ return " ".join(ONES[int(d)] for d in digits if d in "0123456789")
52
+
53
+
54
+ def number_to_words(n: int) -> str:
55
+ """Cardinal number: 2024 -> 'ikki ming yigirma toʻrt'."""
56
+ n = int(n)
57
+ if n < 0:
58
+ return "minus " + number_to_words(-n)
59
+ if n == 0:
60
+ return ONES[0]
61
+ if n >= _LIMIT:
62
+ return digits_to_words(str(n))
63
+ parts = []
64
+ for value, name in _SCALES:
65
+ count, n = divmod(n, value)
66
+ if count == 1:
67
+ parts.append("bir " + name)
68
+ elif count:
69
+ # a hundred that multiplies a scale is bare: 100 000 -> "yuz ming"
70
+ parts.append(_below_thousand(count, bir_yuz=False) + " " + name)
71
+ if n:
72
+ parts.append(_below_thousand(n, bir_yuz=True))
73
+ return " ".join(parts)
74
+
75
+
76
+ def to_ordinal(words: str) -> str:
77
+ """Turn spelled cardinal words into the ordinal form (suffix on the last word)."""
78
+ parts = words.split()
79
+ # 100 -> "yuzinchi", 1000 -> "minginchi" (not "bir yuzinchi")
80
+ if len(parts) == 2 and parts[0] == "bir" and parts[1] in _SCALE_WORDS:
81
+ parts = parts[1:]
82
+ last = parts[-1]
83
+ parts[-1] = last + ("nchi" if last[-1] in _VOWELS else "inchi")
84
+ return " ".join(parts)
85
+
86
+
87
+ def number_to_ordinal(n: int) -> str:
88
+ """Ordinal number: 2 -> 'ikkinchi', 2024 -> 'ikki ming yigirma toʻrtinchi'."""
89
+ return to_ordinal(number_to_words(n))
90
+
91
+
92
+ def read_integer(digits: str) -> str:
93
+ """Read a digit string; a leading zero means it is read digit by digit ('05' -> 'nol besh')."""
94
+ if len(digits) > 1 and digits[0] == "0":
95
+ return digits_to_words(digits)
96
+ return number_to_words(int(digits))
97
+
98
+
99
+ def decimal_to_words(integer: str, fraction: str) -> str:
100
+ """'3', '5' -> 'uch butun oʻndan besh'; '2', '75' -> 'ikki butun yuzdan yetmish besh'."""
101
+ whole = number_to_words(int(integer))
102
+ place = _DECIMAL_PLACES.get(len(fraction))
103
+ if place is None:
104
+ return whole + " butun " + digits_to_words(fraction)
105
+ return whole + " butun " + place + " " + number_to_words(int(fraction))
106
+
107
+
108
+ def attach_suffix(words: str, suffix: str) -> str:
109
+ """Glue a grammatical suffix to a number word: 'qirq' + 'ga' -> 'qirqqa'."""
110
+ if not suffix:
111
+ return words
112
+ if words.endswith("q") and suffix[0] in "gG":
113
+ suffix = "q" + suffix[1:]
114
+ elif words.endswith("k") and suffix[0] in "gG":
115
+ suffix = "k" + suffix[1:]
116
+ return words + suffix
117
+
118
+
119
+ def fraction_to_words(numerator: int, denominator: int) -> str:
120
+ """3/4 -> 'toʻrtdan uch'."""
121
+ return attach_suffix(number_to_words(denominator), "dan") + " " + number_to_words(numerator)
122
+
123
+
124
+ def roman_to_int(roman: str) -> int:
125
+ total = prev = 0
126
+ for ch in reversed(roman.upper()):
127
+ value = _ROMAN[ch]
128
+ if value < prev:
129
+ total -= value
130
+ else:
131
+ total += value
132
+ prev = value
133
+ return total
@@ -0,0 +1,95 @@
1
+ # Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
2
+ # Free for non-commercial use only; modification and redistribution are not allowed.
3
+ # Commercial use requires a license. See the LICENSE file or contact r.raxmonberdiyev10@gmail.com.
4
+
5
+ """Uzbek Cyrillic -> Latin transliteration and mixed-script (homoglyph) repair."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ from .numbers import OKINA
12
+
13
+ TUTUQ = "ʼ" # ʼ (tutuq belgisi)
14
+
15
+ _CYR_VOWELS = frozenset("аеёиоуўэюяы")
16
+
17
+ _CYR2LAT = {
18
+ "а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "ё": "yo", "ж": "j", "з": "z",
19
+ "и": "i", "й": "y", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o", "п": "p",
20
+ "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "x", "ч": "ch", "ш": "sh",
21
+ "щ": "sh", "ъ": TUTUQ, "ы": "i", "ь": "", "э": "e", "ю": "yu", "я": "ya",
22
+ "ў": "o" + OKINA, "қ": "q", "ғ": "g" + OKINA, "ҳ": "h",
23
+ # letters that sneak in from Russian / Kazakh / Tajik keyboards
24
+ "ӯ": "o" + OKINA, "і": "i", "ї": "yi", "є": "ye", "ґ": "g", "ң": "ng",
25
+ "ә": "a", "ө": "o", "ү": "u", "һ": "h", "ҷ": "j",
26
+ }
27
+ _CONTEXT_LETTERS = frozenset("ецҳ")
28
+ _HAS_CYRILLIC = re.compile("[Ѐ-ӿ]")
29
+
30
+
31
+ def cyrillic_to_latin(text: str) -> str:
32
+ """Transliterate Uzbek Cyrillic to the official Latin alphabet, keeping case.
33
+
34
+ Rules beyond one-to-one letters:
35
+ * е -> "ye" at the start of a word or after a vowel/ъ/ь, otherwise "e"
36
+ * ц -> "ts" after a vowel, otherwise "s"
37
+ * сҳ -> "sʼh" (so it is not read as the digraph "sh")
38
+ """
39
+ if not _HAS_CYRILLIC.search(text):
40
+ return text
41
+ out = []
42
+ length = len(text)
43
+ for i, ch in enumerate(text):
44
+ low = ch.lower()
45
+ if low not in _CYR2LAT and low not in _CONTEXT_LETTERS:
46
+ out.append(ch)
47
+ continue
48
+ prev = text[i - 1] if i else ""
49
+ prev_low = prev.lower()
50
+ if low == "е":
51
+ starts_word = not prev.isalpha()
52
+ lat = "ye" if starts_word or prev_low in _CYR_VOWELS or prev_low in "ъь" else "e"
53
+ elif low == "ц":
54
+ lat = "ts" if prev_low in _CYR_VOWELS else "s"
55
+ elif low == "ҳ":
56
+ lat = TUTUQ + "h" if prev_low == "с" else "h"
57
+ else:
58
+ lat = _CYR2LAT[low]
59
+ if lat and ch.isupper():
60
+ nxt = text[i + 1] if i + 1 < length else ""
61
+ all_caps = nxt.isupper() or (prev.isupper() and not nxt.isalpha())
62
+ lat = lat.upper() if all_caps else lat[0].upper() + lat[1:]
63
+ out.append(lat)
64
+ return "".join(out)
65
+
66
+
67
+ # Letters that look identical in Latin and Cyrillic.
68
+ _CYR_LOOKALIKE = {
69
+ "а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "у": "y", "х": "x", "і": "i",
70
+ "ј": "j", "ԛ": "q", "ѕ": "s", "һ": "h",
71
+ "А": "A", "В": "B", "Е": "E", "К": "K", "М": "M", "Н": "H", "О": "O", "Р": "P",
72
+ "С": "C", "Т": "T", "Х": "X", "У": "Y", "І": "I", "Ј": "J", "Ѕ": "S",
73
+ }
74
+ _TO_LATIN = str.maketrans(_CYR_LOOKALIKE)
75
+ _TO_CYRILLIC = str.maketrans({lat: cyr for cyr, lat in _CYR_LOOKALIKE.items() if cyr not in "іјԛѕһІЈЅ"})
76
+ _WORD = re.compile(r"[^\W\d_]+")
77
+
78
+
79
+ def _repair_word(match: "re.Match[str]") -> str:
80
+ word = match.group()
81
+ cyrillic = sum(1 for c in word if "Ѐ" <= c <= "ӿ")
82
+ if not cyrillic or cyrillic == len(word):
83
+ return word
84
+ latin = sum(1 for c in word if "a" <= c.lower() <= "z")
85
+ if not latin:
86
+ return word
87
+ # Majority script wins: fix the look-alike letters of the minority script.
88
+ return word.translate(_TO_LATIN if latin >= cyrillic else _TO_CYRILLIC)
89
+
90
+
91
+ def fix_mixed_script(text: str) -> str:
92
+ """Repair words that mix Latin and Cyrillic look-alikes, e.g. 'Toshkеnt' with a Cyrillic 'е'."""
93
+ if not _HAS_CYRILLIC.search(text):
94
+ return text
95
+ return _WORD.sub(_repair_word, text)
jayron/py.typed ADDED
File without changes
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: jayron
3
+ Version: 0.1.0
4
+ Summary: Uzbek text normalizer for speech recognition (ASR): numbers, dates, Cyrillic, apostrophes, new 2026 alphabet.
5
+ Author: Rakhimberdi Rakhmonberdiev
6
+ License-Expression: LicenseRef-jayron-NonCommercial
7
+ Keywords: uzbek,asr,speech-recognition,text-normalization,wer,transliteration,nlp
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Intended Audience :: Science/Research
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
15
+ Classifier: Topic :: Text Processing :: Linguistic
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7; extra == "dev"
22
+ Requires-Dist: build; extra == "dev"
23
+ Requires-Dist: twine; extra == "dev"
24
+ Dynamic: license-file
25
+
26
+ # jayron
27
+
28
+ Tools for Uzbek speech recognition. The first module is a text normalizer that
29
+ turns written Uzbek into the form a speaker says it, for ASR training targets
30
+ and for scoring (WER/CER). No dependencies, Python 3.9+.
31
+
32
+ > Free for non-commercial use. Commercial use requires a license, see [License](#license).
33
+
34
+ ```bash
35
+ pip install jayron
36
+ ```
37
+
38
+ ```python
39
+ from jayron.normalizers import uz_normilizer
40
+
41
+ uz_normilizer("2024-yil 15-mart kuni soat 14:30 da 1,5 mln so'm to'landi.")
42
+ # 'ikki ming yigirma toʻrtinchi yil oʻn beshinchi mart kuni soat oʻn toʻrt oʻttizda
43
+ # bir butun oʻndan besh million soʻm toʻlandi'
44
+
45
+ uz_normilizer(["Ўзбекистон", "5 ta kitob"]) # lists work too
46
+ # ['oʻzbekiston', 'beshta kitob']
47
+ ```
48
+
49
+ `uz_normalizer` is the same object under the standard spelling.
50
+
51
+ ## What it does
52
+
53
+ | Input | Output |
54
+ |---|---|
55
+ | `O‘zbekiston`, `O'zbekiston`, `O`zbekiston` | `oʻzbekiston` (one apostrophe character) |
56
+ | `Ўзбекистон Республикаси` | `oʻzbekiston respublikasi` |
57
+ | `Toshkеnt` (Cyrillic `е` inside) | `toshkent` |
58
+ | `137`, `1 000 000`, `1,000,000` | `bir yuz oʻttiz yetti`, `bir million` |
59
+ | `2024-yil`, `5-sinf`, `2024 yil`, `1991 y.` | `ikki ming yigirma toʻrtinchi yil`, `beshinchi sinf`, ... |
60
+ | `5ta`, `5 ta`, `40ga`, `10dan` | `beshta`, `beshta`, `qirqqa`, `oʻndan` |
61
+ | `3-xonali`, `30-yillik` | `uch xonali`, `oʻttiz yillik` (cardinal, not ordinal) |
62
+ | `3,5`, `2.75` | `uch butun oʻndan besh`, `ikki butun yuzdan yetmish besh` |
63
+ | `1/2`, `½`, `2½` | `ikkidan bir`, `ikkidan bir`, `ikki butun ikkidan bir` |
64
+ | `25%`, `12,5%ga` | `yigirma besh foiz`, `oʻn ikki butun oʻndan besh foizga` |
65
+ | `$100`, `50 €`, `100 USD`, `$1,5 mln` | `bir yuz dollar`, `ellik yevro`, ..., `bir butun oʻndan besh million dollar` |
66
+ | `5 kg`, `50 m²`, `-5°C` | `besh kilogramm`, `ellik kvadrat metr`, `minus besh gradus` |
67
+ | `14:30`, `soat 9:05da` | `oʻn toʻrt oʻttiz`, `soat toʻqqiz nol beshda` |
68
+ | `12.05.2024`, `2024-05-12`, `15 mart` | `oʻn ikkinchi may ikki ming yigirma toʻrtinchi yil`, ..., `oʻn beshinchi mart` |
69
+ | `5-10 kishi`, `1990-2000-yillar` | `besh oʻn kishi`, `... toʻqsoninchi ikki minginchi yillar` |
70
+ | `XXI asr`, `IV bob` | `yigirma birinchi asr`, `toʻrtinchi bob` |
71
+ | `+998 90 123 45 67` | read group by group |
72
+ | `va h.k.`, `Toshkent sh.`, `№5`, `&` | `va hokazo`, `toshkent shahri`, `raqam besh`, `va` |
73
+ | `[noise]`, `<unk>`, `[1]` | removed |
74
+ | `Café`, `«Salom!»` | `cafe`, `salom` |
75
+
76
+ With the defaults, output contains only `a-z`, `ʻ` and single spaces.
77
+
78
+ ## New alphabet (2026 reform)
79
+
80
+ The reform law (signed 21 Oct 2026, in force from 22 Apr 2027) replaces
81
+ `Oʻ Gʻ Sh Ch` with `Ö Ğ Ş Ç` and drops `Ng` as a separate letter (it stays
82
+ written as n + g). The apostrophe is kept.
83
+
84
+ ```python
85
+ from jayron.normalizers import converter
86
+
87
+ converter("Bog'chaga bordingmi?") # 'Boğçaga bordingmi?'
88
+ converter("Ўзбекистон шаҳри") # 'Özbekiston şahri' (Cyrillic works too)
89
+ converter.to_old("Boğçaga bordingmi?") # 'Bogʻchaga bordingmi?'
90
+ ```
91
+
92
+ Only those four letters change; case, punctuation, digits, `ng`, the tutuq
93
+ (`maʼno`) and `s'h` (`Is'hoq`) are left as they are, and so are links and
94
+ e-mail addresses.
95
+
96
+ To get normalized ASR text in the new alphabet, use `UzbekNormalizer(alphabet="new")`:
97
+
98
+ ```python
99
+ UzbekNormalizer(alphabet="new")("Bog'chaga 5 ta o'quvchi bordi.")
100
+ # 'boğçaga beşta öquvçi bordi'
101
+ ```
102
+
103
+ The normalizer reads either alphabet as input, so a corpus that mixes old
104
+ and new spellings comes out consistent.
105
+
106
+ ## Options
107
+
108
+ ```python
109
+ from jayron.normalizers import UzbekNormalizer
110
+
111
+ norm = UzbekNormalizer(
112
+ alphabet="old", # "new" -> ö ğ ş ç
113
+ apostrophe="'", # default ʻ (U+02BB); "'" if your tokenizer prefers ASCII
114
+ lowercase=True,
115
+ remove_punctuation=True, # False keeps punctuation (for punctuation-aware models)
116
+ transliterate=True, # Cyrillic -> Latin
117
+ expand_numbers=True,
118
+ expand_abbreviations=True, # mln, h.k., sh., %, №, &
119
+ strip_accents=True, # é -> e
120
+ drop_annotations=True, # [noise], <unk>
121
+ custom_rules=[(r"\bAI\b", "sun'iy intellekt")], # your own regex rules, applied first
122
+ )
123
+ ```
124
+
125
+ Use the same settings for training transcripts and for scoring (both references
126
+ and model output), or WER will count formatting differences as errors.
127
+
128
+ Lower-level helpers live in `jayron.normalizers.uz`: `number_to_words`,
129
+ `number_to_ordinal`, `cyrillic_to_latin`, `expand_numbers`.
130
+
131
+ ## Command line
132
+
133
+ ```bash
134
+ jayron-uz-norm "2024-yil 5 ta kitob"
135
+ jayron-uz-norm -i corpus.txt -o corpus.norm.txt
136
+ jayron-uz-norm -i train.jsonl -o train.norm.jsonl --jsonl --field text # NeMo manifest
137
+ jayron-uz-norm --help
138
+ ```
139
+
140
+ ## Conventions and known ambiguities
141
+
142
+ - Hundreds and thousands keep `bir` (`100` → `bir yuz`, `1000` → `bir ming`),
143
+ except when multiplying a larger scale (`100 000` → `yuz ming`) or in ordinals
144
+ (`100-yil` → `yuzinchi yil`).
145
+ - `ʻ` is used for oʻ, gʻ and the tutuq belgisi alike, so the model has one
146
+ apostrophe token.
147
+ - Space-grouped thousands are joined: `5 100` → `besh ming bir yuz`. In plain
148
+ text this is right almost always, but a list such as "5 100" would be merged.
149
+ - A single dot group is a decimal: `1.500` → `bir butun mingdan besh yuz`;
150
+ a single comma group of three digits is thousands: `1,500` → `bir ming besh yuz`.
151
+ - Times are read digit-pair by digit-pair: `14:00` → `oʻn toʻrt nol nol`.
152
+
153
+ ## Development
154
+
155
+ ```bash
156
+ pip install -e '.[dev]'
157
+ pytest
158
+ ```
159
+
160
+ ## License
161
+
162
+ Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
163
+
164
+ jayron is **free for non-commercial use**: personal projects, study, teaching
165
+ and non-profit research. **Commercial use** (companies, paid products or
166
+ services, commercial ASR models or datasets, paid contract work) **needs a
167
+ paid license**. Contact r.raxmonberdiyev10@gmail.com.
168
+
169
+ The source code may not be modified, copied into other projects, or
170
+ redistributed. See [LICENSE](LICENSE) for the full terms.
@@ -0,0 +1,15 @@
1
+ jayron/__init__.py,sha256=Od36H2Mn2ID2xUx7X9qsHx-DWspv7jkAQI5I3EnX61Q,331
2
+ jayron/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ jayron/normalizers/__init__.py,sha256=LKq8zV2qhD1KoMqR-UvepenHINErhwSsMHnMaapV1QM,716
4
+ jayron/normalizers/uz/__init__.py,sha256=CrstU7GY7BnUfpsJqhYS3pwxfAhbGOpfr45YcdU-SXU,906
5
+ jayron/normalizers/uz/__main__.py,sha256=Basn_7a9oA-5RzTo2nyKLq7kIHhf_jhM6T86lAWr8D0,3223
6
+ jayron/normalizers/uz/converter.py,sha256=5czB07ruKQEVIKlKAlAfWCE_VI6ZcXm8sfn6ED-u4mg,5135
7
+ jayron/normalizers/uz/normalizer.py,sha256=XAujRt4hxp5qLrMuSGU_x8LHImObr2EJE_ckLPclTUI,20825
8
+ jayron/normalizers/uz/numbers.py,sha256=U_p2VQ2nPDdMKM3YFc7THcImnpQ_icRNHMZgyhFuwNs,4714
9
+ jayron/normalizers/uz/transliterate.py,sha256=mQoEJfhCu3gtFQbZ_tY2E2CiI7YR05eo0LN-2WhWYII,3873
10
+ jayron-0.1.0.dist-info/licenses/LICENSE,sha256=TVwRhjWpCFjFSm90IaYGekUKHszqyjqhyb9umDt1T5A,3171
11
+ jayron-0.1.0.dist-info/METADATA,sha256=W0WMZWMQl9WcFIMZ0tmQHwi4y5KQVXqkz6yrEhiuMhI,7051
12
+ jayron-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
13
+ jayron-0.1.0.dist-info/entry_points.txt,sha256=SEj-GuxyMgc2A32LJQp3DxHGO1-7FKle760YGETFqu0,71
14
+ jayron-0.1.0.dist-info/top_level.txt,sha256=E4YI7kcJnOO71QCTKVP6uZYBK6qTUrc7oxygax08iyQ,7
15
+ jayron-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jayron-uz-norm = jayron.normalizers.uz.__main__:main
@@ -0,0 +1,71 @@
1
+ jayron License (Non-Commercial, No Derivatives)
2
+
3
+ Copyright (c) 2026 Rakhimberdi Rakhmonberdiev. All rights reserved.
4
+
5
+ The jayron software, including its source code, word lists, rules, regular
6
+ expressions and documentation (the "Software"), is source-available but is
7
+ NOT open source. You may read the source code, but you may use it only as
8
+ described below.
9
+
10
+ 1. FREE NON-COMMERCIAL USE
11
+
12
+ You may download, install and run the unmodified Software, and use the
13
+ text it produces (the "Output"), free of charge, for non-commercial
14
+ purposes only. Non-commercial purposes are:
15
+ (a) personal projects that earn no money;
16
+ (b) study and teaching;
17
+ (c) research at universities, schools or non-profit research
18
+ organizations, including research published in papers.
19
+
20
+ If you publish work that uses the Software, please credit
21
+ "jayron by Rakhimberdi Rakhmonberdiev".
22
+
23
+ 2. COMMERCIAL USE REQUIRES A PAID LICENSE
24
+
25
+ Any commercial use requires a separate written license from the
26
+ copyright holder. Commercial use includes, for example:
27
+ (a) any use by or for a company or other for-profit organization;
28
+ (b) use in, or to build, a product or service that is sold, licensed,
29
+ offered for a fee or funded by advertising;
30
+ (c) use to prepare, normalize, train, evaluate or improve data or
31
+ models (for example speech recognition models) that are used
32
+ commercially;
33
+ (d) use in paid consulting, freelance or contract work.
34
+
35
+ To get a commercial license, contact Rakhimberdi Rakhmonberdiev at
36
+ r.raxmonberdiyev10@gmail.com.
37
+
38
+ 3. WHAT YOU MAY NOT DO
39
+
40
+ Unless the copyright holder has given you written permission, you may not:
41
+ (a) modify, translate, adapt or create derivative works of the
42
+ Software;
43
+ (b) copy the Software or any part of its source code (including its
44
+ rules, word lists and regular expressions) into other software,
45
+ documents or datasets, or re-create it from the source code;
46
+ (c) sell, rent, sublicense or distribute the Software, except that it
47
+ may be distributed unmodified, exactly as published by the
48
+ copyright holder, through the Python Package Index (PyPI) and its
49
+ mirrors;
50
+ (d) remove or change this license or any copyright notice.
51
+
52
+ Copies made automatically when you install or run the Software as
53
+ allowed in section 1 or under a commercial license are permitted.
54
+
55
+ 4. TERMINATION
56
+
57
+ If you break any of these terms, your permission to use the Software
58
+ ends automatically, and you must stop using it and delete all copies.
59
+
60
+ 5. NO WARRANTY
61
+
62
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
63
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
64
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
65
+ NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR
66
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
67
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
68
+ OTHER DEALINGS IN THE SOFTWARE.
69
+
70
+ All rights not expressly granted in this license are reserved by the
71
+ copyright holder.
@@ -0,0 +1 @@
1
+ jayron