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.
@@ -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()