techiaith-speech-normalizer 0.2.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.
- techiaith/speech_normalizer/__init__.py +9 -0
- techiaith/speech_normalizer/tokenization.py +33 -0
- techiaith/speech_normalizer/welsh_data.py +769 -0
- techiaith/speech_normalizer/welsh_normalizer.py +673 -0
- techiaith/speech_normalizer/welsh_number_normalizer.py +587 -0
- techiaith_speech_normalizer-0.2.0.dist-info/METADATA +372 -0
- techiaith_speech_normalizer-0.2.0.dist-info/RECORD +10 -0
- techiaith_speech_normalizer-0.2.0.dist-info/WHEEL +4 -0
- techiaith_speech_normalizer-0.2.0.dist-info/entry_points.txt +2 -0
- techiaith_speech_normalizer-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Welsh Number Normalizer
|
|
4
|
+
|
|
5
|
+
Converts Welsh number words to digit form for WER calculation.
|
|
6
|
+
Handles both the decimal and traditional vigesimal counting systems,
|
|
7
|
+
ordinals, currency, and common units.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from techiaith.speech_normalizer.welsh_number_normalizer import WelshNumberNormalizer
|
|
11
|
+
|
|
12
|
+
num_norm = WelshNumberNormalizer()
|
|
13
|
+
num_norm("tri deg pump") # → "35"
|
|
14
|
+
num_norm("pymtheg ar hugain") # → "35"
|
|
15
|
+
num_norm("mil naw cant pedwar deg tri") # → "1943"
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ============================================================================
|
|
23
|
+
# CARDINAL NUMBER WORDS
|
|
24
|
+
# ============================================================================
|
|
25
|
+
|
|
26
|
+
# Base numbers (masculine forms are default; feminine used in context)
|
|
27
|
+
# Includes soft mutation (SM), aspirate mutation (AM), and nasal mutation (NM)
|
|
28
|
+
UNITS = {
|
|
29
|
+
"dim": 0, "sero": 0,
|
|
30
|
+
"un": 1,
|
|
31
|
+
"dau": 2, "dwy": 2,
|
|
32
|
+
"ddau": 2, "ddwy": 2, # SM of dau/dwy
|
|
33
|
+
"tri": 3, "tair": 3,
|
|
34
|
+
"dri": 3, "dair": 3, # SM of tri/tair
|
|
35
|
+
"thri": 3, "thair": 3, # AM of tri/tair
|
|
36
|
+
"pedwar": 4, "pedair": 4,
|
|
37
|
+
"bedwar": 4, "bedair": 4, # SM of pedwar/pedair
|
|
38
|
+
"phedwar": 4, "phedair": 4, # AM of pedwar/pedair
|
|
39
|
+
"pump": 5, "pum": 5,
|
|
40
|
+
"bum": 5, "bump": 5, # SM of pum/pump
|
|
41
|
+
"phum": 5, "phump": 5, # AM of pum/pump
|
|
42
|
+
"chwech": 6, "chwe": 6,
|
|
43
|
+
"saith": 7,
|
|
44
|
+
"wyth": 8,
|
|
45
|
+
"naw": 9,
|
|
46
|
+
"nau": 9, # SM of naw (after o/yn)
|
|
47
|
+
"deg": 10, "deng": 10,
|
|
48
|
+
"ddeg": 10, "ddeng": 10, # SM of deg/deng
|
|
49
|
+
"un ar ddeg": 11, "un deg un": 11,
|
|
50
|
+
"deuddeg": 12, "un deg dau": 12, "un deg dwy": 12,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
TEENS = {
|
|
54
|
+
"un ar ddeg": 11,
|
|
55
|
+
"deuddeg": 12, "deuddeng": 12,
|
|
56
|
+
"tri ar ddeg": 13, "tair ar ddeg": 13,
|
|
57
|
+
"dri ar ddeg": 13, "dair ar ddeg": 13, # SM
|
|
58
|
+
"pedwar ar ddeg": 14, "pedair ar ddeg": 14,
|
|
59
|
+
"bedwar ar ddeg": 14, "bedair ar ddeg": 14, # SM
|
|
60
|
+
"pymtheg": 15, "pymtheng": 15,
|
|
61
|
+
"bymtheg": 15, "bymtheng": 15, # SM of pymtheg
|
|
62
|
+
"un ar bymtheg": 16,
|
|
63
|
+
"dau ar bymtheg": 17, "dwy ar bymtheg": 17,
|
|
64
|
+
"ddau ar bymtheg": 17, "ddwy ar bymtheg": 17, # SM
|
|
65
|
+
"deunaw": 18, "ddeunaw": 18, # SM of deunaw
|
|
66
|
+
"pedwar ar bymtheg": 19, "pedair ar bymtheg": 19,
|
|
67
|
+
"bedwar ar bymtheg": 19, "bedair ar bymtheg": 19, # SM
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Decimal tens (includes common mutated forms)
|
|
71
|
+
DECIMAL_TENS = {
|
|
72
|
+
"un deg": 10,
|
|
73
|
+
"dau ddeg": 20, "dwy ddeg": 20,
|
|
74
|
+
"ddau ddeg": 20, "ddwy ddeg": 20, # SM
|
|
75
|
+
"tri deg": 30, "tair deg": 30,
|
|
76
|
+
"dri deg": 30, "dair deg": 30, # SM
|
|
77
|
+
"thri deg": 30, "thair deg": 30, # AM
|
|
78
|
+
"pedwar deg": 40, "pedair deg": 40,
|
|
79
|
+
"bedwar deg": 40, "bedair deg": 40, # SM
|
|
80
|
+
"phedwar deg": 40, "phedair deg": 40, # AM
|
|
81
|
+
"pum deg": 50, "bum deg": 50, # SM
|
|
82
|
+
"phum deg": 50, # AM
|
|
83
|
+
"chwe deg": 60,
|
|
84
|
+
"saith deg": 70,
|
|
85
|
+
"wyth deg": 80,
|
|
86
|
+
"naw deg": 90,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Vigesimal multiples of 20
|
|
90
|
+
VIGESIMAL_TWENTIES = {
|
|
91
|
+
"ugain": 20, "hugain": 20,
|
|
92
|
+
"deugain": 40, "ddeugain": 40, # SM of deugain
|
|
93
|
+
"trigain": 60, "drigain": 60, # SM of trigain
|
|
94
|
+
"pedwar ugain": 80, "pedair ugain": 80,
|
|
95
|
+
"bedwar ugain": 80, "bedair ugain": 80, # SM
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Multipliers (with mutated forms)
|
|
99
|
+
MULTIPLIERS = {
|
|
100
|
+
"cant": 100, "gant": 100, # gant = SM of cant
|
|
101
|
+
"can": 100, "gan": 100, # reduced forms
|
|
102
|
+
"mil": 1000, "fil": 1000, # fil = SM of mil
|
|
103
|
+
"miliwn": 1_000_000, "filiwn": 1_000_000, # SM of miliwn
|
|
104
|
+
"biliwn": 1_000_000_000,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# ============================================================================
|
|
108
|
+
# ORDINAL WORDS
|
|
109
|
+
# ============================================================================
|
|
110
|
+
|
|
111
|
+
ORDINALS = {
|
|
112
|
+
"cyntaf": "1af", "cynta": "1af",
|
|
113
|
+
"gyntaf": "1af", "gynta": "1af", # SM of cyntaf
|
|
114
|
+
"ail": "2il",
|
|
115
|
+
"trydydd": "3ydd", "trydedd": "3edd",
|
|
116
|
+
"drydydd": "3ydd", "drydedd": "3edd", # SM of trydydd/trydedd
|
|
117
|
+
"pedwerydd": "4ydd", "pedwaredd": "4edd",
|
|
118
|
+
"bedwerydd": "4ydd", "bedwaredd": "4edd", # SM of pedwerydd/pedwaredd
|
|
119
|
+
"pumed": "5ed", "bumed": "5ed", # SM of pumed
|
|
120
|
+
"chweched": "6ed",
|
|
121
|
+
"seithfed": "7fed",
|
|
122
|
+
"wythfed": "8fed",
|
|
123
|
+
"nawfed": "9fed",
|
|
124
|
+
"degfed": "10fed", "ddegfed": "10fed", # SM of degfed
|
|
125
|
+
"unfed ar ddeg": "11eg",
|
|
126
|
+
"deuddegfed": "12fed",
|
|
127
|
+
"trydydd ar ddeg": "13eg", "trydedd ar ddeg": "13eg",
|
|
128
|
+
"drydydd ar ddeg": "13eg", "drydedd ar ddeg": "13eg", # SM
|
|
129
|
+
"pedwerydd ar ddeg": "14eg", "pedwaredd ar ddeg": "14eg",
|
|
130
|
+
"bedwerydd ar ddeg": "14eg", "bedwaredd ar ddeg": "14eg", # SM
|
|
131
|
+
"pymthegfed": "15fed",
|
|
132
|
+
"unfed ar bymtheg": "16eg",
|
|
133
|
+
"ail ar bymtheg": "17eg",
|
|
134
|
+
"deunawfed": "18fed",
|
|
135
|
+
"pedwerydd ar bymtheg": "19eg",
|
|
136
|
+
"bedwerydd ar bymtheg": "19eg", # SM
|
|
137
|
+
"ugeinfed": "20fed",
|
|
138
|
+
"unfed ar hugain": "21ain",
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# ============================================================================
|
|
142
|
+
# CURRENCY
|
|
143
|
+
# ============================================================================
|
|
144
|
+
|
|
145
|
+
CURRENCY_UNITS = {
|
|
146
|
+
"punt": "£", "bunt": "£", "phunt": "£",
|
|
147
|
+
"ceiniog": "c",
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# ============================================================================
|
|
151
|
+
# CONNECTOR WORDS (ignored during parsing)
|
|
152
|
+
# ============================================================================
|
|
153
|
+
|
|
154
|
+
CONNECTORS = {"a", "ac", "ar", "o"}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class WelshNumberNormalizer:
|
|
158
|
+
"""
|
|
159
|
+
Converts Welsh number words to digit form.
|
|
160
|
+
|
|
161
|
+
Supports:
|
|
162
|
+
- Decimal system: tri deg pump → 35
|
|
163
|
+
- Vigesimal system: pymtheg ar hugain → 35
|
|
164
|
+
- Ordinals: trydydd → 3ydd
|
|
165
|
+
- Currency: pum punt a deg ceiniog → £5.10
|
|
166
|
+
- Multipliers: mil naw cant → 1900
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def __init__(self):
|
|
170
|
+
self._build_patterns()
|
|
171
|
+
|
|
172
|
+
def __call__(self, text: str) -> str:
|
|
173
|
+
return self.normalize(text)
|
|
174
|
+
|
|
175
|
+
def normalize(self, text: str) -> str:
|
|
176
|
+
"""Convert Welsh number words in text to digits."""
|
|
177
|
+
result = text
|
|
178
|
+
result = self._normalize_currency(result)
|
|
179
|
+
result = self._normalize_ordinals(result)
|
|
180
|
+
result = self._normalize_cardinals(result)
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------ build
|
|
184
|
+
def _build_patterns(self):
|
|
185
|
+
# Build a combined vocabulary of all number-related words
|
|
186
|
+
self._number_words = set()
|
|
187
|
+
for d in [UNITS, TEENS, DECIMAL_TENS, VIGESIMAL_TWENTIES, MULTIPLIERS]:
|
|
188
|
+
for phrase in d:
|
|
189
|
+
for w in phrase.split():
|
|
190
|
+
self._number_words.add(w)
|
|
191
|
+
self._number_words.update(CONNECTORS)
|
|
192
|
+
|
|
193
|
+
# Ordinal patterns — sort longest first for greedy matching
|
|
194
|
+
self._ordinal_keys = sorted(ORDINALS.keys(), key=len, reverse=True)
|
|
195
|
+
self._ordinal_pattern = re.compile(
|
|
196
|
+
r'\b(' + '|'.join(re.escape(k) for k in self._ordinal_keys) + r')\b',
|
|
197
|
+
re.IGNORECASE,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Currency pattern: e.g. "pum punt a deg ceiniog"
|
|
201
|
+
currency_words = '|'.join(re.escape(w) for w in CURRENCY_UNITS)
|
|
202
|
+
self._currency_pattern = re.compile(
|
|
203
|
+
r'((?:[\w\s]+?)\s+(?:' + currency_words + r')(?:\s+(?:a|ac)\s+(?:[\w\s]+?)\s+(?:' + currency_words + r'))?)',
|
|
204
|
+
re.IGNORECASE,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# -------------------------------------------------------------- currency
|
|
208
|
+
def _normalize_currency(self, text: str) -> str:
|
|
209
|
+
"""Convert currency expressions like 'pum punt a deg ceiniog' → '£5.10'."""
|
|
210
|
+
currency_words = {"punt", "bunt", "phunt", "ceiniog"}
|
|
211
|
+
tokens = text.split()
|
|
212
|
+
result_tokens = list(tokens) # copy for modification
|
|
213
|
+
i = 0
|
|
214
|
+
|
|
215
|
+
while i < len(result_tokens):
|
|
216
|
+
word = result_tokens[i].lower()
|
|
217
|
+
if word not in currency_words or word == "ceiniog":
|
|
218
|
+
i += 1
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
# Found punt/bunt/phunt — look backwards for number phrase
|
|
222
|
+
num_start = self._find_number_span_reverse(result_tokens, i)
|
|
223
|
+
if num_start == i:
|
|
224
|
+
i += 1
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
num_phrase = ' '.join(result_tokens[num_start:i])
|
|
228
|
+
pounds = self._words_to_number(num_phrase)
|
|
229
|
+
if pounds is None:
|
|
230
|
+
i += 1
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# Check for "a/ac X ceiniog" after
|
|
234
|
+
pence = None
|
|
235
|
+
pence_end = i + 1
|
|
236
|
+
if (i + 1 < len(result_tokens) and
|
|
237
|
+
result_tokens[i + 1].lower() in ("a", "ac") and
|
|
238
|
+
i + 2 < len(result_tokens)):
|
|
239
|
+
# Find ceiniog
|
|
240
|
+
for j in range(i + 2, min(i + 9, len(result_tokens))):
|
|
241
|
+
if result_tokens[j].lower() == "ceiniog":
|
|
242
|
+
pence_phrase = ' '.join(result_tokens[i + 2:j])
|
|
243
|
+
pence = self._words_to_number(pence_phrase)
|
|
244
|
+
if pence is not None:
|
|
245
|
+
pence_end = j + 1
|
|
246
|
+
break
|
|
247
|
+
|
|
248
|
+
if pence is not None:
|
|
249
|
+
replacement = f"£{pounds}.{pence:02d}"
|
|
250
|
+
else:
|
|
251
|
+
replacement = f"£{pounds}"
|
|
252
|
+
|
|
253
|
+
result_tokens[num_start:pence_end] = [replacement]
|
|
254
|
+
i = num_start + 1
|
|
255
|
+
|
|
256
|
+
return ' '.join(result_tokens)
|
|
257
|
+
|
|
258
|
+
def _find_number_span_reverse(self, tokens: list, end: int) -> int:
|
|
259
|
+
"""Find the start of a number word span looking backwards from end."""
|
|
260
|
+
i = end - 1
|
|
261
|
+
has_number = False
|
|
262
|
+
while i >= 0:
|
|
263
|
+
word = tokens[i].lower()
|
|
264
|
+
if word in self._number_words:
|
|
265
|
+
if word not in CONNECTORS:
|
|
266
|
+
has_number = True
|
|
267
|
+
i -= 1
|
|
268
|
+
else:
|
|
269
|
+
break
|
|
270
|
+
start = i + 1
|
|
271
|
+
# Don't start on a connector
|
|
272
|
+
while start < end and tokens[start].lower() in CONNECTORS:
|
|
273
|
+
start += 1
|
|
274
|
+
return start if has_number else end
|
|
275
|
+
|
|
276
|
+
# -------------------------------------------------------------- ordinals
|
|
277
|
+
def _normalize_ordinals(self, text: str) -> str:
|
|
278
|
+
"""Convert ordinal words to digit form."""
|
|
279
|
+
def replace_ordinal(m):
|
|
280
|
+
key = m.group(1).lower()
|
|
281
|
+
return ORDINALS.get(key, m.group(0))
|
|
282
|
+
|
|
283
|
+
return self._ordinal_pattern.sub(replace_ordinal, text)
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------- cardinals
|
|
286
|
+
def _normalize_cardinals(self, text: str) -> str:
|
|
287
|
+
"""Find and replace sequences of number words with digits."""
|
|
288
|
+
tokens = text.split()
|
|
289
|
+
result = []
|
|
290
|
+
i = 0
|
|
291
|
+
|
|
292
|
+
while i < len(tokens):
|
|
293
|
+
# Try to find the longest span of number words starting at i
|
|
294
|
+
span_end = self._find_number_span(tokens, i)
|
|
295
|
+
|
|
296
|
+
if span_end > i:
|
|
297
|
+
number_phrase = ' '.join(tokens[i:span_end])
|
|
298
|
+
value = self._words_to_number(number_phrase)
|
|
299
|
+
if value is not None:
|
|
300
|
+
result.append(str(value))
|
|
301
|
+
i = span_end
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
result.append(tokens[i])
|
|
305
|
+
i += 1
|
|
306
|
+
|
|
307
|
+
return ' '.join(result)
|
|
308
|
+
|
|
309
|
+
def _find_number_span(self, tokens: list, start: int) -> int:
|
|
310
|
+
"""Find the end index of a contiguous number word span."""
|
|
311
|
+
i = start
|
|
312
|
+
has_number_word = False
|
|
313
|
+
|
|
314
|
+
while i < len(tokens):
|
|
315
|
+
word = tokens[i].lower().rstrip('.,;:!?')
|
|
316
|
+
if word in self._number_words:
|
|
317
|
+
if word not in CONNECTORS:
|
|
318
|
+
has_number_word = True
|
|
319
|
+
elif not has_number_word:
|
|
320
|
+
# Don't start a span with a connector
|
|
321
|
+
break
|
|
322
|
+
i += 1
|
|
323
|
+
else:
|
|
324
|
+
break
|
|
325
|
+
|
|
326
|
+
# Don't end on a connector
|
|
327
|
+
while i > start and tokens[i - 1].lower().rstrip('.,;:!?') in CONNECTORS:
|
|
328
|
+
i -= 1
|
|
329
|
+
|
|
330
|
+
return i if has_number_word else start
|
|
331
|
+
|
|
332
|
+
def _words_to_number(self, text: str) -> Optional[int]:
|
|
333
|
+
"""
|
|
334
|
+
Parse a Welsh number phrase into an integer.
|
|
335
|
+
|
|
336
|
+
Handles both decimal (tri deg pump = 35) and vigesimal
|
|
337
|
+
(pymtheg ar hugain = 35) systems, plus multipliers
|
|
338
|
+
(mil naw cant pedwar deg tri = 1943).
|
|
339
|
+
"""
|
|
340
|
+
text = text.lower().strip()
|
|
341
|
+
if not text:
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
# Try direct lookups first (single tokens or known phrases)
|
|
345
|
+
if text in UNITS:
|
|
346
|
+
return UNITS[text]
|
|
347
|
+
if text in TEENS:
|
|
348
|
+
return TEENS[text]
|
|
349
|
+
|
|
350
|
+
# Try vigesimal pattern: X ar hugain/deugain/trigain/pedwar ugain
|
|
351
|
+
value = self._try_vigesimal(text)
|
|
352
|
+
if value is not None:
|
|
353
|
+
return value
|
|
354
|
+
|
|
355
|
+
# Try decimal pattern: tri deg pump
|
|
356
|
+
value = self._try_decimal(text)
|
|
357
|
+
if value is not None:
|
|
358
|
+
return value
|
|
359
|
+
|
|
360
|
+
# Try with multipliers: mil naw cant pedwar deg tri
|
|
361
|
+
value = self._try_with_multipliers(text)
|
|
362
|
+
if value is not None:
|
|
363
|
+
return value
|
|
364
|
+
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
def _try_vigesimal(self, text: str) -> Optional[int]:
|
|
368
|
+
"""Parse vigesimal expressions like 'pymtheg ar hugain' (15+20=35)."""
|
|
369
|
+
for vig_word, vig_value in VIGESIMAL_TWENTIES.items():
|
|
370
|
+
# Pattern: "X ar hugain/deugain/etc"
|
|
371
|
+
for connector in ["ar ", "a "]:
|
|
372
|
+
suffix = connector + vig_word
|
|
373
|
+
if text.endswith(suffix):
|
|
374
|
+
prefix = text[:-len(suffix)].strip()
|
|
375
|
+
if not prefix:
|
|
376
|
+
return vig_value
|
|
377
|
+
base = self._words_to_number(prefix)
|
|
378
|
+
if base is not None:
|
|
379
|
+
return base + vig_value
|
|
380
|
+
|
|
381
|
+
# Pattern: just "ugain", "deugain", etc.
|
|
382
|
+
if text == vig_word:
|
|
383
|
+
return vig_value
|
|
384
|
+
|
|
385
|
+
return None
|
|
386
|
+
|
|
387
|
+
def _try_decimal(self, text: str) -> Optional[int]:
|
|
388
|
+
"""Parse decimal expressions like 'tri deg pump' (30+5=35)."""
|
|
389
|
+
for dec_phrase, dec_value in DECIMAL_TENS.items():
|
|
390
|
+
if text.startswith(dec_phrase):
|
|
391
|
+
rest = text[len(dec_phrase):].strip()
|
|
392
|
+
if not rest:
|
|
393
|
+
return dec_value
|
|
394
|
+
unit = self._words_to_number(rest)
|
|
395
|
+
if unit is not None and unit < 10:
|
|
396
|
+
return dec_value + unit
|
|
397
|
+
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
def _try_with_multipliers(self, text: str) -> Optional[int]:
|
|
401
|
+
"""Parse expressions with multipliers: mil, cant, miliwn, biliwn."""
|
|
402
|
+
words = text.split()
|
|
403
|
+
total = 0
|
|
404
|
+
current = 0
|
|
405
|
+
i = 0
|
|
406
|
+
|
|
407
|
+
while i < len(words):
|
|
408
|
+
word = words[i]
|
|
409
|
+
|
|
410
|
+
# Skip connectors
|
|
411
|
+
if word in CONNECTORS:
|
|
412
|
+
i += 1
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
# Check for multi-word tens (e.g. "tri deg", "pedwar deg")
|
|
416
|
+
if i + 1 < len(words):
|
|
417
|
+
two_word = word + ' ' + words[i + 1]
|
|
418
|
+
if two_word in DECIMAL_TENS:
|
|
419
|
+
current += DECIMAL_TENS[two_word]
|
|
420
|
+
i += 2
|
|
421
|
+
# Check for following unit
|
|
422
|
+
if i < len(words) and words[i] in UNITS and UNITS[words[i]] < 10:
|
|
423
|
+
current += UNITS[words[i]]
|
|
424
|
+
i += 1
|
|
425
|
+
continue
|
|
426
|
+
if two_word in VIGESIMAL_TWENTIES:
|
|
427
|
+
current += VIGESIMAL_TWENTIES[two_word]
|
|
428
|
+
i += 2
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
# Check for teens (multi-word)
|
|
432
|
+
matched_teen = False
|
|
433
|
+
for teen_phrase, teen_val in sorted(TEENS.items(), key=lambda x: len(x[0]), reverse=True):
|
|
434
|
+
teen_words = teen_phrase.split()
|
|
435
|
+
if words[i:i + len(teen_words)] == teen_words:
|
|
436
|
+
current += teen_val
|
|
437
|
+
i += len(teen_words)
|
|
438
|
+
matched_teen = True
|
|
439
|
+
break
|
|
440
|
+
if matched_teen:
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
# Check for multipliers
|
|
444
|
+
if word in MULTIPLIERS:
|
|
445
|
+
mult = MULTIPLIERS[word]
|
|
446
|
+
if current == 0:
|
|
447
|
+
current = 1
|
|
448
|
+
if mult >= 1000:
|
|
449
|
+
total += current * mult
|
|
450
|
+
current = 0
|
|
451
|
+
else:
|
|
452
|
+
current *= mult
|
|
453
|
+
i += 1
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
# Check for units
|
|
457
|
+
if word in UNITS:
|
|
458
|
+
current += UNITS[word]
|
|
459
|
+
i += 1
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
# Unrecognised word — bail
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
total += current
|
|
466
|
+
return total if total > 0 or text.strip() in ("dim", "sero") else None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def main():
|
|
470
|
+
import sys
|
|
471
|
+
import argparse
|
|
472
|
+
|
|
473
|
+
parser = argparse.ArgumentParser(
|
|
474
|
+
description='Welsh number normalizer - convert Welsh number words to digits',
|
|
475
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
476
|
+
epilog='''
|
|
477
|
+
Examples:
|
|
478
|
+
# Normalize number words in text
|
|
479
|
+
python welsh_number_normalizer.py --text "tri deg pump"
|
|
480
|
+
|
|
481
|
+
# Normalize an SRT file
|
|
482
|
+
python welsh_number_normalizer.py --srt input.srt
|
|
483
|
+
|
|
484
|
+
# Normalize an SRT file and save to output file
|
|
485
|
+
python welsh_number_normalizer.py --srt input.srt --output output.srt
|
|
486
|
+
|
|
487
|
+
# Normalize an SRT file keeping original text above normalized text
|
|
488
|
+
python welsh_number_normalizer.py --srt input.srt --output output.srt --include-original
|
|
489
|
+
'''
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
input_group = parser.add_mutually_exclusive_group(required=True)
|
|
493
|
+
input_group.add_argument(
|
|
494
|
+
'--text',
|
|
495
|
+
type=str,
|
|
496
|
+
help='Text containing Welsh number words to normalize'
|
|
497
|
+
)
|
|
498
|
+
input_group.add_argument(
|
|
499
|
+
'--srt',
|
|
500
|
+
type=str,
|
|
501
|
+
help='Path to SRT file to normalize'
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
parser.add_argument(
|
|
505
|
+
'--output', '-o',
|
|
506
|
+
type=str,
|
|
507
|
+
help='Output file path (only for SRT files)'
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
parser.add_argument(
|
|
511
|
+
'--include-original',
|
|
512
|
+
action='store_true',
|
|
513
|
+
help='Include original text above normalized text in SRT output'
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
args = parser.parse_args()
|
|
517
|
+
|
|
518
|
+
normalizer = WelshNumberNormalizer()
|
|
519
|
+
|
|
520
|
+
if args.text:
|
|
521
|
+
if args.output:
|
|
522
|
+
print("Warning: --output is ignored for text input")
|
|
523
|
+
|
|
524
|
+
normalized = normalizer.normalize(args.text)
|
|
525
|
+
print("Original:")
|
|
526
|
+
print(args.text)
|
|
527
|
+
print("\nNormalized:")
|
|
528
|
+
print(normalized)
|
|
529
|
+
|
|
530
|
+
elif args.srt:
|
|
531
|
+
try:
|
|
532
|
+
with open(args.srt, 'r', encoding='utf-8') as f:
|
|
533
|
+
content = f.read()
|
|
534
|
+
|
|
535
|
+
normalized_lines = []
|
|
536
|
+
lines = content.split('\n')
|
|
537
|
+
i = 0
|
|
538
|
+
|
|
539
|
+
while i < len(lines):
|
|
540
|
+
line = lines[i].strip()
|
|
541
|
+
|
|
542
|
+
if line.isdigit():
|
|
543
|
+
normalized_lines.append(line)
|
|
544
|
+
i += 1
|
|
545
|
+
|
|
546
|
+
if i < len(lines):
|
|
547
|
+
normalized_lines.append(lines[i].strip())
|
|
548
|
+
i += 1
|
|
549
|
+
|
|
550
|
+
subtitle_lines = []
|
|
551
|
+
while i < len(lines) and lines[i].strip():
|
|
552
|
+
subtitle_lines.append(lines[i])
|
|
553
|
+
i += 1
|
|
554
|
+
|
|
555
|
+
subtitle_text = '\n'.join(subtitle_lines)
|
|
556
|
+
normalized_text = normalizer.normalize(subtitle_text)
|
|
557
|
+
|
|
558
|
+
if args.include_original:
|
|
559
|
+
normalized_lines.append(subtitle_text)
|
|
560
|
+
normalized_lines.append(normalized_text)
|
|
561
|
+
else:
|
|
562
|
+
normalized_lines.append(normalized_text)
|
|
563
|
+
|
|
564
|
+
normalized_lines.append('')
|
|
565
|
+
else:
|
|
566
|
+
i += 1
|
|
567
|
+
|
|
568
|
+
result = '\n'.join(normalized_lines)
|
|
569
|
+
|
|
570
|
+
if args.output:
|
|
571
|
+
with open(args.output, 'w', encoding='utf-8') as f:
|
|
572
|
+
f.write(result)
|
|
573
|
+
print(f"Normalized SRT file saved to: {args.output}")
|
|
574
|
+
else:
|
|
575
|
+
print("Normalized SRT content:")
|
|
576
|
+
print(result)
|
|
577
|
+
|
|
578
|
+
except FileNotFoundError:
|
|
579
|
+
print(f"Error: File not found: {args.srt}")
|
|
580
|
+
sys.exit(1)
|
|
581
|
+
except Exception as e:
|
|
582
|
+
print(f"Error processing SRT file: {e}")
|
|
583
|
+
sys.exit(1)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
if __name__ == '__main__':
|
|
587
|
+
main()
|