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,673 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Welsh Verbatim Speech Normalizer - Configurable Version
|
|
4
|
+
|
|
5
|
+
This module provides configurable normalization for Welsh verbatim speech
|
|
6
|
+
transcriptions. You decide which patterns to normalize.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from welsh_normalizer import WelshNormalizer
|
|
10
|
+
|
|
11
|
+
# Create with your choices
|
|
12
|
+
normalizer = WelshNormalizer(
|
|
13
|
+
remove_tags=True,
|
|
14
|
+
remove_asterisks=True,
|
|
15
|
+
expand_pronouns=True,
|
|
16
|
+
expand_contractions=True,
|
|
17
|
+
standardize_spelling=True,
|
|
18
|
+
join_hyphenated_compounds=True, # gwasg-y-fferm → gwasgyfferm
|
|
19
|
+
# etc.
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
result = normalizer.normalize("bo' nw'n gwbod <anadlu> *Facebook*")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
import sys
|
|
27
|
+
import unicodedata
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from enum import Enum
|
|
33
|
+
|
|
34
|
+
# Handle imports for both module and script execution
|
|
35
|
+
try:
|
|
36
|
+
from .tokenization import tokenize_with_punctuation, detokenize
|
|
37
|
+
except ImportError:
|
|
38
|
+
# Running as script, add parent directory to path
|
|
39
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
40
|
+
from tokenization import tokenize_with_punctuation, detokenize
|
|
41
|
+
|
|
42
|
+
# Import shared Welsh language data
|
|
43
|
+
try:
|
|
44
|
+
from .welsh_data import (
|
|
45
|
+
PRONOUN_CORRECTIONS,
|
|
46
|
+
VERB_CONTRACTIONS,
|
|
47
|
+
MAE_CONTRACTIONS,
|
|
48
|
+
OTHER_CONTRACTIONS,
|
|
49
|
+
PLURAL_REDUCTIONS,
|
|
50
|
+
VERB_REDUCTIONS,
|
|
51
|
+
ADJECTIVE_REDUCTIONS,
|
|
52
|
+
INTERNAL_REDUCTIONS,
|
|
53
|
+
SPELLING_CORRECTIONS,
|
|
54
|
+
HESITATION_STANDARDIZATION,
|
|
55
|
+
MA_FOLLOWERS,
|
|
56
|
+
DI_WEDI_SAFE_PRECEDERS,
|
|
57
|
+
MULTI_WORD_REPLACEMENTS,
|
|
58
|
+
)
|
|
59
|
+
except ImportError:
|
|
60
|
+
from normalization.welsh_data import (
|
|
61
|
+
PRONOUN_CORRECTIONS,
|
|
62
|
+
VERB_CONTRACTIONS,
|
|
63
|
+
MAE_CONTRACTIONS,
|
|
64
|
+
OTHER_CONTRACTIONS,
|
|
65
|
+
PLURAL_REDUCTIONS,
|
|
66
|
+
VERB_REDUCTIONS,
|
|
67
|
+
ADJECTIVE_REDUCTIONS,
|
|
68
|
+
INTERNAL_REDUCTIONS,
|
|
69
|
+
SPELLING_CORRECTIONS,
|
|
70
|
+
HESITATION_STANDARDIZATION,
|
|
71
|
+
MA_FOLLOWERS,
|
|
72
|
+
DI_WEDI_SAFE_PRECEDERS,
|
|
73
|
+
MULTI_WORD_REPLACEMENTS,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FlagType(Enum):
|
|
78
|
+
"""Types of flags for uncertain normalizations."""
|
|
79
|
+
CONTEXT_UNCERTAIN = "context_uncertain"
|
|
80
|
+
REGIONAL_VARIANT = "regional_variant"
|
|
81
|
+
AMBIGUOUS = "ambiguous"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class NormalizationFlag:
|
|
86
|
+
"""A flag indicating an uncertain normalization."""
|
|
87
|
+
original: str
|
|
88
|
+
normalized: str
|
|
89
|
+
flag_type: FlagType
|
|
90
|
+
context: str
|
|
91
|
+
position: int
|
|
92
|
+
suggestion: str
|
|
93
|
+
|
|
94
|
+
def __str__(self):
|
|
95
|
+
"""String representation for CSV serialization."""
|
|
96
|
+
return f"{self.flag_type.value}:{self.original}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class WelshNormalizer:
|
|
100
|
+
"""
|
|
101
|
+
Configurable normalizer for Welsh verbatim speech transcriptions.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
remove_tags: bool = False,
|
|
107
|
+
remove_asterisks: bool = False,
|
|
108
|
+
expand_pronouns: bool = True,
|
|
109
|
+
expand_verb_contractions: bool = True,
|
|
110
|
+
expand_mae_contractions: bool = True,
|
|
111
|
+
normalize_ma_with_followers: bool = True, # New: use MA_FOLLOWERS lookup table
|
|
112
|
+
expand_di_contextual: bool = True, # New: context-aware 'di expansion
|
|
113
|
+
expand_other_contractions: bool = True,
|
|
114
|
+
standardize_plurals: bool = True,
|
|
115
|
+
standardize_verbs: bool = True,
|
|
116
|
+
standardize_adjectives: bool = True,
|
|
117
|
+
standardize_internal: bool = True,
|
|
118
|
+
standardize_spelling: bool = True,
|
|
119
|
+
remove_hesitations: bool = False,
|
|
120
|
+
standardize_hesitations: bool = True,
|
|
121
|
+
remove_false_starts: bool = True,
|
|
122
|
+
expand_annotations: bool = True, # {llafar|ysgrifenedig} -> ysgrifenedig, e.g. {saith|7} -> 7
|
|
123
|
+
normalize_whitespace: bool = True,
|
|
124
|
+
flag_uncertain: bool = True, # New: flag uncertain normalizations
|
|
125
|
+
expand_multi_word: bool = True, # New: multi-word phrase replacements
|
|
126
|
+
normalize_numbers: bool = False, # Convert number words to digits (for WER)
|
|
127
|
+
join_hyphenated_compounds: bool = False, # Join hyphenated compounds into single tokens (for WER)
|
|
128
|
+
lowercase: bool = False, # Lowercase output (for WER calculation)
|
|
129
|
+
strip_punctuation: bool = False, # Strip punctuation/symbols (for WER calculation)
|
|
130
|
+
custom_corrections: Optional[Dict[str, str]] = None,
|
|
131
|
+
custom_multi_word: Optional[Dict[str, str]] = None,
|
|
132
|
+
):
|
|
133
|
+
self.remove_tags = remove_tags
|
|
134
|
+
self.remove_asterisks = remove_asterisks
|
|
135
|
+
self.expand_pronouns = expand_pronouns
|
|
136
|
+
self.expand_verb_contractions = expand_verb_contractions
|
|
137
|
+
self.expand_mae_contractions = expand_mae_contractions
|
|
138
|
+
self.normalize_ma_with_followers = normalize_ma_with_followers
|
|
139
|
+
self.expand_di_contextual = expand_di_contextual
|
|
140
|
+
self.expand_other_contractions = expand_other_contractions
|
|
141
|
+
self.standardize_plurals = standardize_plurals
|
|
142
|
+
self.standardize_verbs = standardize_verbs
|
|
143
|
+
self.standardize_adjectives = standardize_adjectives
|
|
144
|
+
self.standardize_internal = standardize_internal
|
|
145
|
+
self.standardize_spelling = standardize_spelling
|
|
146
|
+
self.remove_hesitations = remove_hesitations
|
|
147
|
+
self.standardize_hesitations = standardize_hesitations
|
|
148
|
+
self.remove_false_starts = remove_false_starts
|
|
149
|
+
self.expand_annotations = expand_annotations
|
|
150
|
+
self.normalize_whitespace = normalize_whitespace
|
|
151
|
+
self.flag_uncertain = flag_uncertain
|
|
152
|
+
self.expand_multi_word = expand_multi_word
|
|
153
|
+
self.normalize_numbers = normalize_numbers
|
|
154
|
+
self.join_hyphenated_compounds = join_hyphenated_compounds
|
|
155
|
+
self.lowercase = lowercase
|
|
156
|
+
self.strip_punctuation = strip_punctuation
|
|
157
|
+
|
|
158
|
+
if self.normalize_numbers:
|
|
159
|
+
from .welsh_number_normalizer import WelshNumberNormalizer
|
|
160
|
+
self._number_normalizer = WelshNumberNormalizer()
|
|
161
|
+
|
|
162
|
+
self._build_corrections(custom_corrections, custom_multi_word)
|
|
163
|
+
|
|
164
|
+
self._tag_pattern = re.compile(r'<[^>]+>')
|
|
165
|
+
self._asterisk_pattern = re.compile(r'\*([^*]+)\*')
|
|
166
|
+
# {spoken|written} annotation: keep the written form, e.g. {saith|7} -> 7
|
|
167
|
+
self._annotation_pattern = re.compile(r'\{[^{}|]*\|([^{}]*)\}')
|
|
168
|
+
self._hesitation_pattern = re.compile(r'\b(yy+m*|yrm|ymm*)\b', re.IGNORECASE)
|
|
169
|
+
self._false_start_pattern = re.compile(r'\b\w+-\s')
|
|
170
|
+
self._multi_space_pattern = re.compile(r' +')
|
|
171
|
+
self._punctuation_pattern = re.compile(r'[^\w\s]', re.UNICODE)
|
|
172
|
+
self._hyphen_compound_pattern = re.compile(r'(?<=\w)[-\u2013\u2014](?=\w)')
|
|
173
|
+
|
|
174
|
+
def _build_corrections(self, custom: Optional[Dict[str, str]], custom_multi: Optional[Dict[str, str]]):
|
|
175
|
+
self.corrections = {}
|
|
176
|
+
self.multi_word_corrections = {}
|
|
177
|
+
|
|
178
|
+
if self.expand_pronouns:
|
|
179
|
+
self.corrections.update(PRONOUN_CORRECTIONS)
|
|
180
|
+
if self.expand_verb_contractions:
|
|
181
|
+
self.corrections.update(VERB_CONTRACTIONS)
|
|
182
|
+
if self.expand_mae_contractions:
|
|
183
|
+
self.corrections.update(MAE_CONTRACTIONS)
|
|
184
|
+
if self.expand_other_contractions:
|
|
185
|
+
self.corrections.update(OTHER_CONTRACTIONS)
|
|
186
|
+
if self.standardize_plurals:
|
|
187
|
+
self.corrections.update(PLURAL_REDUCTIONS)
|
|
188
|
+
if self.standardize_verbs:
|
|
189
|
+
self.corrections.update(VERB_REDUCTIONS)
|
|
190
|
+
if self.standardize_adjectives:
|
|
191
|
+
self.corrections.update(ADJECTIVE_REDUCTIONS)
|
|
192
|
+
if self.standardize_internal:
|
|
193
|
+
self.corrections.update(INTERNAL_REDUCTIONS)
|
|
194
|
+
if self.standardize_spelling:
|
|
195
|
+
self.corrections.update(SPELLING_CORRECTIONS)
|
|
196
|
+
if self.standardize_hesitations:
|
|
197
|
+
self.corrections.update(HESITATION_STANDARDIZATION)
|
|
198
|
+
if custom:
|
|
199
|
+
self.corrections.update(custom)
|
|
200
|
+
|
|
201
|
+
if self.expand_multi_word:
|
|
202
|
+
self.multi_word_corrections.update(MULTI_WORD_REPLACEMENTS)
|
|
203
|
+
if custom_multi:
|
|
204
|
+
self.multi_word_corrections.update(custom_multi)
|
|
205
|
+
|
|
206
|
+
self.corrections_lower = {k.lower(): v.lower() for k, v in self.corrections.items()}
|
|
207
|
+
|
|
208
|
+
# Build lookup structure for multi-word patterns
|
|
209
|
+
# Group by number of words for efficient matching
|
|
210
|
+
self.multi_word_by_length = {}
|
|
211
|
+
for phrase, replacement in self.multi_word_corrections.items():
|
|
212
|
+
phrase_lower = phrase.lower()
|
|
213
|
+
word_count = len(phrase_lower.split())
|
|
214
|
+
if word_count not in self.multi_word_by_length:
|
|
215
|
+
self.multi_word_by_length[word_count] = {}
|
|
216
|
+
self.multi_word_by_length[word_count][phrase_lower] = replacement
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def __call__(self, text: str) -> str:
|
|
221
|
+
"""Make the normalizer callable, consistent with Whisper's normalizer API."""
|
|
222
|
+
return self.normalize(text)
|
|
223
|
+
|
|
224
|
+
def normalize(self, text: str) -> str:
|
|
225
|
+
"""
|
|
226
|
+
Apply normalization and return the result.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Normalized text
|
|
230
|
+
"""
|
|
231
|
+
result, _ = self.normalize_with_flags(text)
|
|
232
|
+
return result
|
|
233
|
+
|
|
234
|
+
def normalize_with_flags(self, text: str) -> Tuple[str, List[NormalizationFlag]]:
|
|
235
|
+
"""
|
|
236
|
+
Apply normalization and return both result and any flags.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
Tuple of (normalized_text, list_of_flags)
|
|
240
|
+
"""
|
|
241
|
+
if not text:
|
|
242
|
+
return text, []
|
|
243
|
+
|
|
244
|
+
flags = []
|
|
245
|
+
result = unicodedata.normalize('NFKC', text)
|
|
246
|
+
|
|
247
|
+
if self.expand_annotations:
|
|
248
|
+
result = self._annotation_pattern.sub(r'\1', result)
|
|
249
|
+
|
|
250
|
+
if self.remove_tags:
|
|
251
|
+
result = self._tag_pattern.sub('', result)
|
|
252
|
+
|
|
253
|
+
if self.remove_asterisks:
|
|
254
|
+
result = self._asterisk_pattern.sub(r'\1', result)
|
|
255
|
+
|
|
256
|
+
if self.remove_hesitations:
|
|
257
|
+
result = self._hesitation_pattern.sub('', result)
|
|
258
|
+
|
|
259
|
+
if self.remove_false_starts:
|
|
260
|
+
result = self._false_start_pattern.sub('', result)
|
|
261
|
+
|
|
262
|
+
if self.corrections or self.multi_word_corrections or self.normalize_ma_with_followers or self.expand_di_contextual:
|
|
263
|
+
result, new_flags = self._apply_corrections(result, text)
|
|
264
|
+
flags.extend(new_flags)
|
|
265
|
+
|
|
266
|
+
if self.normalize_numbers:
|
|
267
|
+
result = self._number_normalizer.normalize(result)
|
|
268
|
+
|
|
269
|
+
if self.join_hyphenated_compounds:
|
|
270
|
+
result = self._hyphen_compound_pattern.sub(' ', result)
|
|
271
|
+
|
|
272
|
+
if self.strip_punctuation:
|
|
273
|
+
result = self._punctuation_pattern.sub(' ', result)
|
|
274
|
+
|
|
275
|
+
if self.lowercase:
|
|
276
|
+
result = result.lower()
|
|
277
|
+
|
|
278
|
+
if self.normalize_whitespace:
|
|
279
|
+
result = self._multi_space_pattern.sub(' ', result)
|
|
280
|
+
result = result.strip()
|
|
281
|
+
|
|
282
|
+
return result, flags
|
|
283
|
+
|
|
284
|
+
def _apply_corrections(self, text: str, full_text: str) -> Tuple[str, List[NormalizationFlag]]:
|
|
285
|
+
# Tokenize with punctuation separation
|
|
286
|
+
tokens = tokenize_with_punctuation(text)
|
|
287
|
+
|
|
288
|
+
# Extract just the words for processing
|
|
289
|
+
word_indices = [i for i, (token, ttype) in enumerate(tokens) if ttype == 'word']
|
|
290
|
+
|
|
291
|
+
flags = []
|
|
292
|
+
i = 0 # Index in word_indices
|
|
293
|
+
|
|
294
|
+
while i < len(word_indices):
|
|
295
|
+
token_idx = word_indices[i]
|
|
296
|
+
word = tokens[token_idx][0]
|
|
297
|
+
word_lower = word.lower()
|
|
298
|
+
|
|
299
|
+
# Check if preceded by an apostrophe — if so, treat as combined form
|
|
300
|
+
# e.g. tokens ['", "di"] should be matched as "'di"
|
|
301
|
+
has_leading_apos = (token_idx > 0 and tokens[token_idx - 1][0] in ("'", "\u2019")
|
|
302
|
+
and tokens[token_idx - 1][1] == 'punct')
|
|
303
|
+
if has_leading_apos:
|
|
304
|
+
word_with_apos = "'" + word
|
|
305
|
+
word_with_apos_lower = "'" + word_lower
|
|
306
|
+
else:
|
|
307
|
+
word_with_apos = None
|
|
308
|
+
word_with_apos_lower = None
|
|
309
|
+
|
|
310
|
+
# Get context (previous and next words, not punctuation)
|
|
311
|
+
prev_word = tokens[word_indices[i-1]][0].lower() if i > 0 else None
|
|
312
|
+
next_word = tokens[word_indices[i+1]][0].lower() if i + 1 < len(word_indices) else None
|
|
313
|
+
|
|
314
|
+
# Try multi-word patterns first (check longest patterns first)
|
|
315
|
+
multi_word_match = False
|
|
316
|
+
if self.expand_multi_word:
|
|
317
|
+
for word_count in sorted(self.multi_word_by_length.keys(), reverse=True):
|
|
318
|
+
if i + word_count <= len(word_indices):
|
|
319
|
+
# Build the potential phrase from current position
|
|
320
|
+
phrase_word_indices = [word_indices[i+j] for j in range(word_count)]
|
|
321
|
+
phrase_words = [tokens[idx][0] for idx in phrase_word_indices]
|
|
322
|
+
phrase_lower = ' '.join(w.lower() for w in phrase_words)
|
|
323
|
+
|
|
324
|
+
# Also try with apostrophe-prefixed first word
|
|
325
|
+
first_token_idx = phrase_word_indices[0]
|
|
326
|
+
first_has_apos = (first_token_idx > 0
|
|
327
|
+
and tokens[first_token_idx - 1][0] in ("'", "\u2019")
|
|
328
|
+
and tokens[first_token_idx - 1][1] == 'punct')
|
|
329
|
+
if first_has_apos:
|
|
330
|
+
phrase_apos_lower = "'" + phrase_lower
|
|
331
|
+
else:
|
|
332
|
+
phrase_apos_lower = None
|
|
333
|
+
|
|
334
|
+
lookup = self.multi_word_by_length[word_count]
|
|
335
|
+
matched_key = None
|
|
336
|
+
if phrase_apos_lower and phrase_apos_lower in lookup:
|
|
337
|
+
matched_key = phrase_apos_lower
|
|
338
|
+
elif phrase_lower in lookup:
|
|
339
|
+
matched_key = phrase_lower
|
|
340
|
+
|
|
341
|
+
if matched_key:
|
|
342
|
+
replacement = lookup[matched_key]
|
|
343
|
+
|
|
344
|
+
# Preserve capitalization of first word
|
|
345
|
+
if phrase_words[0][0].isupper():
|
|
346
|
+
replacement = replacement[0].upper() + replacement[1:]
|
|
347
|
+
|
|
348
|
+
# Replace first word token with the replacement
|
|
349
|
+
tokens[first_token_idx] = (replacement, 'word')
|
|
350
|
+
# Remove the leading apostrophe if matched via apostrophe-prefixed key
|
|
351
|
+
if matched_key == phrase_apos_lower and first_has_apos:
|
|
352
|
+
tokens.pop(first_token_idx - 1)
|
|
353
|
+
word_indices = [wi - 1 if wi > first_token_idx - 1 else wi for wi in word_indices]
|
|
354
|
+
phrase_word_indices = [wi - 1 for wi in phrase_word_indices]
|
|
355
|
+
# Remove the other matched word tokens and intervening non-word tokens
|
|
356
|
+
for idx in reversed(phrase_word_indices[1:]):
|
|
357
|
+
# Remove any preceding space/punct tokens between this word and the previous one
|
|
358
|
+
while idx > 0 and tokens[idx-1][1] in ('space', 'punct'):
|
|
359
|
+
tokens.pop(idx-1)
|
|
360
|
+
word_indices = [wi - 1 if wi > idx-1 else wi for wi in word_indices]
|
|
361
|
+
idx -= 1
|
|
362
|
+
tokens.pop(idx)
|
|
363
|
+
# Adjust word_indices after removal
|
|
364
|
+
word_indices = [wi - 1 if wi > idx else wi for wi in word_indices]
|
|
365
|
+
|
|
366
|
+
i += word_count # Skip all words in the matched phrase
|
|
367
|
+
multi_word_match = True
|
|
368
|
+
break
|
|
369
|
+
|
|
370
|
+
if multi_word_match:
|
|
371
|
+
break
|
|
372
|
+
|
|
373
|
+
if multi_word_match:
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
# Handle ma' with MA_FOLLOWERS lookup table
|
|
377
|
+
if word_lower == "ma'" and self.normalize_ma_with_followers:
|
|
378
|
+
# ma' becomes mae
|
|
379
|
+
ma_normalized = "Mae" if word[0].isupper() else "mae"
|
|
380
|
+
tokens[token_idx] = (ma_normalized, 'word')
|
|
381
|
+
|
|
382
|
+
# Check if next word should be normalized (try apostrophe-prefixed form too)
|
|
383
|
+
if next_word:
|
|
384
|
+
next_token_idx = word_indices[i + 1]
|
|
385
|
+
next_has_apos = (next_token_idx > 0
|
|
386
|
+
and tokens[next_token_idx - 1][0] in ("'", "\u2019")
|
|
387
|
+
and tokens[next_token_idx - 1][1] == 'punct')
|
|
388
|
+
next_lookup = "'" + next_word if next_has_apos else next_word
|
|
389
|
+
|
|
390
|
+
if next_lookup in MA_FOLLOWERS:
|
|
391
|
+
normalized_follower = MA_FOLLOWERS[next_lookup]
|
|
392
|
+
tokens[next_token_idx] = (normalized_follower, 'word')
|
|
393
|
+
# Remove the leading apostrophe if present
|
|
394
|
+
if next_has_apos:
|
|
395
|
+
tokens.pop(next_token_idx - 1)
|
|
396
|
+
word_indices = [wi - 1 if wi > next_token_idx - 1 else wi for wi in word_indices]
|
|
397
|
+
i += 2 # Skip both ma' and the follower
|
|
398
|
+
continue
|
|
399
|
+
elif next_word in MA_FOLLOWERS:
|
|
400
|
+
normalized_follower = MA_FOLLOWERS[next_word]
|
|
401
|
+
tokens[next_token_idx] = (normalized_follower, 'word')
|
|
402
|
+
i += 2
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
i += 1
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
# Handle 'di with context
|
|
409
|
+
elif word_with_apos_lower == "'di" and self.expand_di_contextual:
|
|
410
|
+
# Check last word of prev token (handles expanded forms like "dw i" from "dwi")
|
|
411
|
+
prev_check = prev_word.split()[-1].rstrip("'") if prev_word else None
|
|
412
|
+
if prev_check and prev_check in DI_WEDI_SAFE_PRECEDERS:
|
|
413
|
+
tokens[token_idx] = ("wedi", 'word')
|
|
414
|
+
# Remove the leading apostrophe token
|
|
415
|
+
tokens.pop(token_idx - 1)
|
|
416
|
+
word_indices = [wi - 1 if wi > token_idx - 1 else wi for wi in word_indices]
|
|
417
|
+
else:
|
|
418
|
+
# Keep as-is
|
|
419
|
+
if self.flag_uncertain:
|
|
420
|
+
flag = NormalizationFlag(
|
|
421
|
+
original=word_with_apos,
|
|
422
|
+
normalized=word_with_apos,
|
|
423
|
+
flag_type=FlagType.CONTEXT_UNCERTAIN,
|
|
424
|
+
context=f"...'di not preceded by pronoun",
|
|
425
|
+
position=i,
|
|
426
|
+
suggestion="'di not preceded by pronoun - keeping as-is"
|
|
427
|
+
)
|
|
428
|
+
flags.append(flag)
|
|
429
|
+
i += 1
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
# Regular word corrections — try apostrophe-prefixed form first
|
|
433
|
+
# (handles tokenizer splitting e.g. ' + na that should match 'na → yna)
|
|
434
|
+
matched = False
|
|
435
|
+
if word_with_apos and word_with_apos in self.corrections:
|
|
436
|
+
tokens[token_idx] = (self.corrections[word_with_apos], 'word')
|
|
437
|
+
tokens.pop(token_idx - 1)
|
|
438
|
+
word_indices = [wi - 1 if wi > token_idx - 1 else wi for wi in word_indices]
|
|
439
|
+
matched = True
|
|
440
|
+
elif word_with_apos_lower and word_with_apos_lower in self.corrections_lower:
|
|
441
|
+
replacement = self.corrections_lower[word_with_apos_lower]
|
|
442
|
+
tokens[token_idx] = (replacement, 'word')
|
|
443
|
+
tokens.pop(token_idx - 1)
|
|
444
|
+
word_indices = [wi - 1 if wi > token_idx - 1 else wi for wi in word_indices]
|
|
445
|
+
matched = True
|
|
446
|
+
elif word in self.corrections:
|
|
447
|
+
tokens[token_idx] = (self.corrections[word], 'word')
|
|
448
|
+
matched = True
|
|
449
|
+
elif word_lower in self.corrections_lower:
|
|
450
|
+
replacement = self.corrections_lower[word_lower]
|
|
451
|
+
if word[0].isupper():
|
|
452
|
+
replacement = replacement.capitalize()
|
|
453
|
+
tokens[token_idx] = (replacement, 'word')
|
|
454
|
+
matched = True
|
|
455
|
+
# else: keep token as-is
|
|
456
|
+
|
|
457
|
+
i += 1
|
|
458
|
+
|
|
459
|
+
return detokenize(tokens), flags
|
|
460
|
+
|
|
461
|
+
def get_flags_summary(self, flags: List[NormalizationFlag]) -> str:
|
|
462
|
+
"""Generate a human-readable summary of flags."""
|
|
463
|
+
if not flags:
|
|
464
|
+
return "No uncertain cases flagged."
|
|
465
|
+
|
|
466
|
+
lines = [f"Found {len(flags)} uncertain case(s):", ""]
|
|
467
|
+
for i, flag in enumerate(flags, 1):
|
|
468
|
+
lines.append(f"{i}. '{flag.original}' at position {flag.position}")
|
|
469
|
+
lines.append(f" Context: {flag.context}")
|
|
470
|
+
lines.append(f" Suggestion: {flag.suggestion}")
|
|
471
|
+
lines.append("")
|
|
472
|
+
|
|
473
|
+
return "\n".join(lines)
|
|
474
|
+
|
|
475
|
+
def describe_config(self) -> str:
|
|
476
|
+
lines = ["Welsh Normalizer Configuration:", "=" * 40]
|
|
477
|
+
settings = [
|
|
478
|
+
("Remove non-speech tags", self.remove_tags),
|
|
479
|
+
("Remove asterisks", self.remove_asterisks),
|
|
480
|
+
("Expand pronouns (nw->nhw)", self.expand_pronouns),
|
|
481
|
+
("Expand verb contractions (ca'l->cael)", self.expand_verb_contractions),
|
|
482
|
+
("Expand mae contractions (ma'->mae)", self.expand_mae_contractions),
|
|
483
|
+
("Normalize ma' with followers (ma' nw->mae nhw)", self.normalize_ma_with_followers),
|
|
484
|
+
("Expand 'di contextually (dwi 'di->dwi wedi)", self.expand_di_contextual),
|
|
485
|
+
("Standardize plurals (petha->pethau)", self.standardize_plurals),
|
|
486
|
+
("Standardize verbs (dechre->dechrau)", self.standardize_verbs),
|
|
487
|
+
("Standardize adjectives (olaf->ola)", self.standardize_adjectives),
|
|
488
|
+
("Standardize internal (gwbod->gwybod)", self.standardize_internal),
|
|
489
|
+
("Standardize spelling", self.standardize_spelling),
|
|
490
|
+
("Remove hesitations", self.remove_hesitations),
|
|
491
|
+
("Expand annotations ({saith|7}->7)", self.expand_annotations),
|
|
492
|
+
("Normalize numbers to digits (for WER)", self.normalize_numbers),
|
|
493
|
+
("Join hyphenated compounds (for WER)", self.join_hyphenated_compounds),
|
|
494
|
+
("Lowercase output (for WER)", self.lowercase),
|
|
495
|
+
("Strip punctuation (for WER)", self.strip_punctuation),
|
|
496
|
+
("Flag uncertain cases", self.flag_uncertain),
|
|
497
|
+
]
|
|
498
|
+
for name, enabled in settings:
|
|
499
|
+
status = "ON" if enabled else "OFF"
|
|
500
|
+
lines.append(f" [{status}] {name}")
|
|
501
|
+
return "\n".join(lines)
|
|
502
|
+
|
|
503
|
+
def normalize_srt_file(self, srt_path: str, output_path: Optional[str] = None, include_original: bool = False) -> str:
|
|
504
|
+
"""
|
|
505
|
+
Normalize the subtitle text content of an SRT file.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
srt_path: Path to the input SRT file
|
|
509
|
+
output_path: Optional path for the output file. If None, returns normalized content.
|
|
510
|
+
include_original: If True, include original text above normalized text
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
The normalized SRT content as a string
|
|
514
|
+
"""
|
|
515
|
+
# Read the SRT file
|
|
516
|
+
with open(srt_path, 'r', encoding='utf-8') as f:
|
|
517
|
+
content = f.read()
|
|
518
|
+
|
|
519
|
+
# Parse SRT format: sequence number, timestamp, subtitle text, blank line
|
|
520
|
+
normalized_lines = []
|
|
521
|
+
lines = content.split('\n')
|
|
522
|
+
i = 0
|
|
523
|
+
|
|
524
|
+
while i < len(lines):
|
|
525
|
+
line = lines[i].strip()
|
|
526
|
+
|
|
527
|
+
# Check if this is a sequence number (digits only)
|
|
528
|
+
if line.isdigit():
|
|
529
|
+
normalized_lines.append(line)
|
|
530
|
+
i += 1
|
|
531
|
+
|
|
532
|
+
# Next line should be timestamp
|
|
533
|
+
if i < len(lines):
|
|
534
|
+
timestamp_line = lines[i].strip()
|
|
535
|
+
normalized_lines.append(timestamp_line)
|
|
536
|
+
i += 1
|
|
537
|
+
|
|
538
|
+
# Collect subtitle text until blank line
|
|
539
|
+
subtitle_lines = []
|
|
540
|
+
while i < len(lines) and lines[i].strip():
|
|
541
|
+
subtitle_lines.append(lines[i])
|
|
542
|
+
i += 1
|
|
543
|
+
|
|
544
|
+
# Normalize the subtitle text
|
|
545
|
+
subtitle_text = '\n'.join(subtitle_lines)
|
|
546
|
+
normalized_text = self.normalize(subtitle_text)
|
|
547
|
+
|
|
548
|
+
# Add original and normalized text
|
|
549
|
+
if include_original:
|
|
550
|
+
normalized_lines.append(subtitle_text)
|
|
551
|
+
normalized_lines.append(normalized_text)
|
|
552
|
+
else:
|
|
553
|
+
normalized_lines.append(normalized_text)
|
|
554
|
+
|
|
555
|
+
# Add blank line separator
|
|
556
|
+
normalized_lines.append('')
|
|
557
|
+
else:
|
|
558
|
+
# Skip empty lines or malformed content
|
|
559
|
+
i += 1
|
|
560
|
+
|
|
561
|
+
# Join all lines
|
|
562
|
+
result = '\n'.join(normalized_lines)
|
|
563
|
+
|
|
564
|
+
# Write to output file if specified
|
|
565
|
+
if output_path:
|
|
566
|
+
with open(output_path, 'w', encoding='utf-8') as f:
|
|
567
|
+
f.write(result)
|
|
568
|
+
|
|
569
|
+
return result
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def main():
|
|
573
|
+
import argparse
|
|
574
|
+
|
|
575
|
+
parser = argparse.ArgumentParser(
|
|
576
|
+
description='Welsh text normalizer - normalize text or SRT subtitle files',
|
|
577
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
578
|
+
epilog='''
|
|
579
|
+
Examples:
|
|
580
|
+
# Normalize example text
|
|
581
|
+
python welsh_normalizer.py --text "bo' nw'n gwbod"
|
|
582
|
+
|
|
583
|
+
# Normalize an SRT file
|
|
584
|
+
python welsh_normalizer.py --srt input.srt
|
|
585
|
+
|
|
586
|
+
# Normalize an SRT file and save to output file
|
|
587
|
+
python welsh_normalizer.py --srt input.srt --output output.srt
|
|
588
|
+
|
|
589
|
+
# Normalize with original text included above normalized text
|
|
590
|
+
python welsh_normalizer.py --srt input.srt --output output.srt --include-original
|
|
591
|
+
'''
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
# Create mutually exclusive group for input type
|
|
595
|
+
input_group = parser.add_mutually_exclusive_group(required=True)
|
|
596
|
+
input_group.add_argument(
|
|
597
|
+
'--text',
|
|
598
|
+
type=str,
|
|
599
|
+
help='Text to normalize'
|
|
600
|
+
)
|
|
601
|
+
input_group.add_argument(
|
|
602
|
+
'--srt',
|
|
603
|
+
type=str,
|
|
604
|
+
help='Path to SRT file to normalize'
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
parser.add_argument(
|
|
608
|
+
'--output', '-o',
|
|
609
|
+
type=str,
|
|
610
|
+
help='Output file path (only for SRT files)'
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
parser.add_argument(
|
|
614
|
+
'--include-original',
|
|
615
|
+
action='store_true',
|
|
616
|
+
help='Include original text above normalized text in SRT output'
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
parser.add_argument(
|
|
620
|
+
'--show-config',
|
|
621
|
+
action='store_true',
|
|
622
|
+
help='Show normalizer configuration'
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
args = parser.parse_args()
|
|
626
|
+
|
|
627
|
+
# Create normalizer instance
|
|
628
|
+
normalizer = WelshNormalizer()
|
|
629
|
+
|
|
630
|
+
# Show configuration if requested
|
|
631
|
+
if args.show_config:
|
|
632
|
+
print(normalizer.describe_config())
|
|
633
|
+
print()
|
|
634
|
+
|
|
635
|
+
# Process text input
|
|
636
|
+
if args.text:
|
|
637
|
+
if args.output:
|
|
638
|
+
print("Warning: --output is ignored for text input")
|
|
639
|
+
|
|
640
|
+
normalized = normalizer.normalize(args.text)
|
|
641
|
+
print("Original:")
|
|
642
|
+
print(args.text)
|
|
643
|
+
print("\nNormalized:")
|
|
644
|
+
print(normalized)
|
|
645
|
+
|
|
646
|
+
# Process SRT file input
|
|
647
|
+
elif args.srt:
|
|
648
|
+
if args.include_original and not args.srt:
|
|
649
|
+
print("Warning: --include-original only applies to SRT files")
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
normalized = normalizer.normalize_srt_file(
|
|
653
|
+
args.srt,
|
|
654
|
+
args.output,
|
|
655
|
+
include_original=args.include_original
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
if args.output:
|
|
659
|
+
print(f"Normalized SRT file saved to: {args.output}")
|
|
660
|
+
else:
|
|
661
|
+
print("Normalized SRT content:")
|
|
662
|
+
print(normalized)
|
|
663
|
+
|
|
664
|
+
except FileNotFoundError:
|
|
665
|
+
print(f"Error: File not found: {args.srt}")
|
|
666
|
+
sys.exit(1)
|
|
667
|
+
except Exception as e:
|
|
668
|
+
print(f"Error processing SRT file: {e}")
|
|
669
|
+
sys.exit(1)
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
if __name__ == '__main__':
|
|
673
|
+
main()
|