teddybear082 commited on
Commit
6d8882c
·
2 Parent(s): 871e190ec11370

Merge pull request #8 from teddybear082/feature_text_preprocessing

Browse files
app/config.py CHANGED
@@ -41,7 +41,12 @@ class Config:
41
  VOICES_DIR = os.environ.get('POCKET_TTS_VOICES_DIR', None)
42
 
43
  # Streaming default
44
- STREAM_DEFAULT = os.environ.get('POCKET_TTS_STREAM_DEFAULT', 'true').lower() == 'true'
 
 
 
 
 
45
 
46
  # Docker detection
47
  @staticmethod
 
41
  VOICES_DIR = os.environ.get('POCKET_TTS_VOICES_DIR', None)
42
 
43
  # Streaming default
44
+ STREAM_DEFAULT = os.environ.get('POCKET_TTS_STREAM_DEFAULT', 'false').lower() == 'true'
45
+
46
+ # Text preprocessing default
47
+ TEXT_PREPROCESS_DEFAULT = (
48
+ os.environ.get('POCKET_TTS_TEXT_PREPROCESS_DEFAULT', 'false').lower() == 'true'
49
+ )
50
 
51
  # Docker detection
52
  @staticmethod
app/routes.py CHANGED
@@ -22,6 +22,7 @@ from app.services.audio import (
22
  validate_format,
23
  write_wav_header,
24
  )
 
25
  from app.services.tts import get_tts_service
26
 
27
  logger = get_logger('routes')
@@ -29,6 +30,18 @@ logger = get_logger('routes')
29
  # Create blueprint
30
  api = Blueprint('api', __name__)
31
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  @api.route('/')
34
  def home():
@@ -147,7 +160,13 @@ def generate_speech():
147
  target_format,
148
  )
149
  use_streaming = False
150
-
 
 
 
 
 
 
151
  if use_streaming:
152
  return _stream_audio(tts, voice_state, text, target_format)
153
  return _generate_file(tts, voice_state, text, target_format)
 
22
  validate_format,
23
  write_wav_header,
24
  )
25
+ from app.services.preprocess import TextPreprocessor
26
  from app.services.tts import get_tts_service
27
 
28
  logger = get_logger('routes')
 
30
  # Create blueprint
31
  api = Blueprint('api', __name__)
32
 
33
+ # Create text preprocessor instance, some options changed from defaults
34
+ text_preprocessor = TextPreprocessor(
35
+ remove_urls=False,
36
+ remove_emails=False,
37
+ remove_html=True,
38
+ remove_hashtags=True,
39
+ remove_mentions=False,
40
+ remove_punctuation=False,
41
+ remove_stopwords=False,
42
+ remove_extra_whitespace=False,
43
+ )
44
+
45
 
46
  @api.route('/')
47
  def home():
 
160
  target_format,
161
  )
162
  use_streaming = False
163
+ # Check if text preprocessing should be used
164
+ use_text_preprocess = current_app.config.get('TEXT_PREPROCESS_DEFAULT', False)
165
+ # Preprocess text
166
+ if use_text_preprocess:
167
+ # logger.info(f'Preprocessing text: {text}')
168
+ text = text_preprocessor.process(text)
169
+ # logger.info(f'Preprocessed text: {text}')
170
  if use_streaming:
171
  return _stream_audio(tts, voice_state, text, target_format)
172
  return _generate_file(tts, voice_state, text, target_format)
app/services/preprocess.py ADDED
@@ -0,0 +1,1093 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Adapted and supplemented from origional at https://github.com/KittenML/KittenTTS/blob/main/kittentts/preprocess.py
3
+ See license at: https://github.com/KittenML/KittenTTS/blob/main/LICENSE (Apache 2.0)
4
+ """
5
+
6
+ import re
7
+ import unicodedata
8
+
9
+ # ─────────────────────────────────────────────
10
+ # Number → Words conversion
11
+ # ─────────────────────────────────────────────
12
+
13
+ _ONES = [
14
+ '',
15
+ 'one',
16
+ 'two',
17
+ 'three',
18
+ 'four',
19
+ 'five',
20
+ 'six',
21
+ 'seven',
22
+ 'eight',
23
+ 'nine',
24
+ 'ten',
25
+ 'eleven',
26
+ 'twelve',
27
+ 'thirteen',
28
+ 'fourteen',
29
+ 'fifteen',
30
+ 'sixteen',
31
+ 'seventeen',
32
+ 'eighteen',
33
+ 'nineteen',
34
+ ]
35
+ _TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety']
36
+ _SCALE = ['', 'thousand', 'million', 'billion', 'trillion']
37
+
38
+ _ORDINAL_EXCEPTIONS = {
39
+ 'one': 'first',
40
+ 'two': 'second',
41
+ 'three': 'third',
42
+ 'four': 'fourth',
43
+ 'five': 'fifth',
44
+ 'six': 'sixth',
45
+ 'seven': 'seventh',
46
+ 'eight': 'eighth',
47
+ 'nine': 'ninth',
48
+ 'twelve': 'twelfth',
49
+ }
50
+
51
+ _CURRENCY_SYMBOLS = {
52
+ '$': 'dollar',
53
+ '€': 'euro',
54
+ '£': 'pound',
55
+ '¥': 'yen',
56
+ '₹': 'rupee',
57
+ '₩': 'won',
58
+ '₿': 'bitcoin',
59
+ }
60
+
61
+ _CURRENCY_SCALE_MAP = {
62
+ 'K': 'thousand',
63
+ 'M': 'million',
64
+ 'B': 'billion',
65
+ 'T': 'trillion',
66
+ 'thousand': 'thousand',
67
+ 'million': 'million',
68
+ 'billion': 'billion',
69
+ 'trillion': 'trillion',
70
+ }
71
+
72
+ _ROMAN = [
73
+ (1000, 'M'),
74
+ (900, 'CM'),
75
+ (500, 'D'),
76
+ (400, 'CD'),
77
+ (100, 'C'),
78
+ (90, 'XC'),
79
+ (50, 'L'),
80
+ (40, 'XL'),
81
+ (10, 'X'),
82
+ (9, 'IX'),
83
+ (5, 'V'),
84
+ (4, 'IV'),
85
+ (1, 'I'),
86
+ ]
87
+ _RE_ROMAN = re.compile(r'\b(M{0,4})(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})\b')
88
+
89
+
90
+ def _three_digits_to_words(n: int) -> str:
91
+ """Convert a number 0–999 to English words."""
92
+ if n == 0:
93
+ return ''
94
+ parts = []
95
+ hundreds = n // 100
96
+ remainder = n % 100
97
+ if hundreds:
98
+ parts.append(f'{_ONES[hundreds]} hundred')
99
+ if remainder < 20:
100
+ if remainder:
101
+ parts.append(_ONES[remainder])
102
+ else:
103
+ tens_word = _TENS[remainder // 10]
104
+ ones_word = _ONES[remainder % 10]
105
+ parts.append(f'{tens_word}-{ones_word}' if ones_word else tens_word)
106
+ return ' '.join(parts)
107
+
108
+
109
+ def number_to_words(n: int) -> str:
110
+ """
111
+ Convert an integer to its English word representation.
112
+
113
+ Examples:
114
+ 1200 → "twelve hundred"
115
+ 1000 → "one thousand"
116
+ 1_000_000 → "one million"
117
+ -42 → "negative forty-two"
118
+ 0 → "zero"
119
+ """
120
+ if not isinstance(n, int):
121
+ n = int(n)
122
+ if n == 0:
123
+ return 'zero'
124
+ if n < 0:
125
+ return f'negative {number_to_words(-n)}'
126
+
127
+ # X00–X999 read as "X hundred" (e.g. 1200 → "twelve hundred")
128
+ # Exclude exact multiples of 1000 (1000 → "one thousand", not "ten hundred")
129
+ if 100 <= n <= 9999 and n % 100 == 0 and n % 1000 != 0:
130
+ hundreds = n // 100
131
+ if hundreds < 20:
132
+ return f'{_ONES[hundreds]} hundred'
133
+
134
+ parts = []
135
+ for _i, scale in enumerate(_SCALE):
136
+ chunk = n % 1000
137
+ if chunk:
138
+ chunk_words = _three_digits_to_words(chunk)
139
+ parts.append(f'{chunk_words} {scale}'.strip() if scale else chunk_words)
140
+ n //= 1000
141
+ if n == 0:
142
+ break
143
+
144
+ return ' '.join(reversed(parts))
145
+
146
+
147
+ def float_to_words(value, decimal_sep: str = 'point') -> str:
148
+ """
149
+ Convert a float (or numeric string) to words, reading decimal digits individually.
150
+ Accepts a string to preserve trailing zeros (e.g. "1.50" → "one point five zero").
151
+
152
+ Examples:
153
+ 3.14 → "three point one four"
154
+ -0.5 → "negative zero point five"
155
+ "3.10" → "three point one zero"
156
+ 1.007 → "one point zero zero seven"
157
+ """
158
+ text = value if isinstance(value, str) else f'{value}'
159
+ negative = text.startswith('-')
160
+ if negative:
161
+ text = text[1:]
162
+
163
+ if '.' in text:
164
+ int_part, dec_part = text.split('.', 1)
165
+ int_words = number_to_words(int(int_part)) if int_part else 'zero'
166
+ # Read each decimal digit individually; "0" → "zero"
167
+ digit_map = ['zero'] + _ONES[1:] # index 0 → "zero"
168
+ dec_words = ' '.join(digit_map[int(d)] for d in dec_part)
169
+ result = f'{int_words} {decimal_sep} {dec_words}'
170
+ else:
171
+ result = number_to_words(int(text))
172
+
173
+ return f'negative {result}' if negative else result
174
+
175
+
176
+ def roman_to_int(s: str) -> int:
177
+ """Convert a Roman numeral string to an integer."""
178
+ val = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000}
179
+ result = 0
180
+ prev = 0
181
+ for ch in reversed(s.upper()):
182
+ curr = val[ch]
183
+ result += curr if curr >= prev else -curr
184
+ prev = curr
185
+ return result
186
+
187
+
188
+ # ─────────────────────────────────────────────
189
+ # Regex patterns
190
+ # ─────────────────────────────────────────────
191
+
192
+ _RE_URL = re.compile(r'https?://\S+|www\.\S+')
193
+ _RE_EMAIL = re.compile(r'\b[\w.+-]+@[\w-]+\.[a-z]{2,}\b', re.IGNORECASE)
194
+ _RE_HASHTAG = re.compile(r'#\w+')
195
+ _RE_MENTION = re.compile(r'@\w+')
196
+ _RE_HTML = re.compile(r'<[^>]+>')
197
+ _RE_PUNCT = re.compile(r'[^\w\s]')
198
+ _RE_SPACES = re.compile(r'\s+')
199
+ _RE_AI = re.compile(r'\bAI\b')
200
+ _RE_DOT_COM = re.compile(r'\.com\b', re.IGNORECASE)
201
+ _RE_PLUS = re.compile(r'\+')
202
+ _RE_AMPERSAND = re.compile(r'&')
203
+ _RE_AT_SYMBOL = re.compile(r'@')
204
+ _RE_NEWLINE = re.compile(r'[\r\n]+')
205
+ _RE_TILDE = re.compile(r'~')
206
+
207
+ _MONTH_MAP = {
208
+ 'Jan': 'January',
209
+ 'Feb': 'February',
210
+ 'Mar': 'March',
211
+ 'Apr': 'April',
212
+ 'Jun': 'June',
213
+ 'Jul': 'July',
214
+ 'Aug': 'August',
215
+ 'Sep': 'September',
216
+ 'Sept': 'September',
217
+ 'Oct': 'October',
218
+ 'Nov': 'November',
219
+ 'Dec': 'December',
220
+ }
221
+
222
+ # Regex looks for Title Case months followed by a period or a digit
223
+ # We handle "May" separately because it's a common word.
224
+ _RE_MONTHS = re.compile(r'\b(Jan|Feb|Mar|Apr|Jun|Jul|Aug|Sep|Sept|Oct|Nov|Dec)\.?\b(?=\s*\d|\s*$)')
225
+ _RE_MAY = re.compile(r'\bMay\b(?=\s*\d)') # Only expand May if followed by a number (May 5)
226
+
227
+ # Number: do NOT match a leading minus if it is immediately preceded by a letter
228
+ # (handles "gpt-3", "gpl-3", "v-2" etc.)
229
+ _RE_NUMBER = re.compile(r'(?<![a-zA-Z])-?[\d,]+(?:\.\d+)?')
230
+
231
+ # Ordinals: 1st, 2nd, 3rd, 4th … 21st, 101st …
232
+ _RE_ORDINAL = re.compile(r'\b(\d+)(st|nd|rd|th)\b', re.IGNORECASE)
233
+
234
+ # Percentages: 50%, 3.5%
235
+ _RE_PERCENT = re.compile(r'(-?[\d,]+(?:\.\d+)?)\s*%')
236
+
237
+ # Currency: $100, €1,200.50, £50, $85K, $2.5M (optional scale suffix)
238
+ _RE_CURRENCY = re.compile(
239
+ r'([$€£¥₹₩₿])\s*([\d,]+(?:\.\d+)?)\s*(million|billion|trillion|thousand|[KMBT])?\b',
240
+ re.IGNORECASE,
241
+ )
242
+
243
+ # Time: 3:30pm, 14:00, 3:30 AM — requires 2-digit minutes so "3:0" (score) doesn't match
244
+ _RE_TIME = re.compile(r'\b(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?\b', re.IGNORECASE)
245
+
246
+ # Ranges: 10-20, 100-200 (both sides numeric, hyphen between them)
247
+ _RE_RANGE = re.compile(r'(?<!\w)(\d+)-(\d+)(?!\w)')
248
+
249
+ # Version/model names: gpt-3, gpt-3.5, v2.0, Python-3.10, GPL-3
250
+ # Letter(s) + hyphen + digit(s) [+ more version parts]
251
+ _RE_MODEL_VER = re.compile(r'\b([a-zA-Z][a-zA-Z0-9]*)-(\d[\d.]*)(?=[^\d.]|$)')
252
+
253
+ # Measurement units glued to numbers: 100km, 50kg, 25°C, 5GB
254
+ _RE_UNIT = re.compile(
255
+ r'(\d+(?:\.\d+)?)\s*(km|kg|mg|ml|gb|mb|kb|tb|hz|khz|mhz|ghz|mph|kph|°[cCfF]|[cCfF]°|ms|ns|µs)\b',
256
+ re.IGNORECASE,
257
+ )
258
+
259
+ # Scale suffixes (uppercase only to avoid ambiguity): 7B, 340M, 1.5K, 2T
260
+ # Must NOT be preceded by a letter (so 'MB' is handled by unit regex first)
261
+ _RE_SCALE = re.compile(r'(?<![a-zA-Z])(\d+(?:\.\d+)?)\s*([KMBT])(?![a-zA-Z\d])')
262
+
263
+ # Scientific notation: 1e-4, 2.5e10, 6.022E23
264
+ _RE_SCI = re.compile(r'(?<![a-zA-Z\d])(-?\d+(?:\.\d+)?)[eE]([+-]?\d+)(?![a-zA-Z\d])')
265
+
266
+ # Fractions: 1/2, 3/4, 2/3
267
+ _RE_FRACTION = re.compile(r'\b(\d+)\s*/\s*(\d+)\b')
268
+
269
+ # Decades: 80s, 90s, 1980s, 2020s (number ending in 0 followed by 's')
270
+ _RE_DECADE = re.compile(r'\b(\d{1,3})0s\b')
271
+
272
+ # Leading decimal (no digit before the dot): .5, .75
273
+ _RE_LEAD_DEC = re.compile(r'(?<!\d)\.([\d])')
274
+
275
+
276
+ # ─────────────────────────────────────────────
277
+ # Expansion helpers
278
+ # ─────────────────────────────────────────────
279
+ def expand_abbreviations(text: str) -> str:
280
+ """
281
+ Handles specific abbreviations before lowercase normalization.
282
+ AI -> A.I.
283
+ .com -> dot com
284
+ """
285
+ # 1. AI to A.I. (Case sensitive)
286
+ text = _RE_AI.sub('A.I.', text)
287
+ # 2. .com to dot com
288
+ text = _RE_DOT_COM.sub(' dot com', text)
289
+ return text
290
+
291
+
292
+ def expand_symbols(text: str) -> str:
293
+ """
294
+ Translates mathematical and connector symbols to words.
295
+ """
296
+ text = _RE_PLUS.sub(' plus ', text)
297
+ text = _RE_AMPERSAND.sub(' and ', text)
298
+ text = _RE_AT_SYMBOL.sub(' at ', text)
299
+ return text
300
+
301
+
302
+ def _ordinal_suffix(n: int) -> str:
303
+ """Return the ordinal word for n (e.g. 1 → 'first', 5 → 'fifth', 21 → 'twenty-first')."""
304
+ word = number_to_words(n)
305
+ # For hyphenated compounds like "twenty-one", convert only the last part
306
+ if '-' in word:
307
+ prefix, last = word.rsplit('-', 1)
308
+ joiner = '-'
309
+ else:
310
+ parts = word.rsplit(' ', 1)
311
+ prefix, last, joiner = (parts[0], parts[1], ' ') if len(parts) == 2 else ('', parts[0], '')
312
+
313
+ # Check exception table
314
+ for base, ordinal in _ORDINAL_EXCEPTIONS.items():
315
+ if last == base:
316
+ last_ord = ordinal
317
+ break
318
+ else:
319
+ # General rule
320
+ if last.endswith('t'):
321
+ last_ord = last + 'h'
322
+ elif last.endswith('e'):
323
+ last_ord = last[:-1] + 'th'
324
+ else:
325
+ last_ord = last + 'th'
326
+
327
+ return f'{prefix}{joiner}{last_ord}' if prefix else last_ord
328
+
329
+
330
+ def expand_ordinals(text: str) -> str:
331
+ """
332
+ Convert ordinal numbers to words.
333
+
334
+ Examples:
335
+ "1st place" → "first place"
336
+ "2nd floor" → "second floor"
337
+ "3rd base" → "third base"
338
+ "21st century" → "twenty-first century"
339
+ "100th day" → "one hundredth day"
340
+ """
341
+
342
+ def _replace(m: re.Match) -> str:
343
+ return _ordinal_suffix(int(m.group(1)))
344
+
345
+ return _RE_ORDINAL.sub(_replace, text)
346
+
347
+
348
+ def expand_percentages(text: str) -> str:
349
+ """
350
+ Expand percentage expressions.
351
+
352
+ Examples:
353
+ "50% off" → "fifty percent off"
354
+ "3.5% rate" → "three point five percent rate"
355
+ "-2% change" → "negative two percent change"
356
+ """
357
+
358
+ def _replace(m: re.Match) -> str:
359
+ raw = m.group(1).replace(',', '')
360
+ if '.' in raw:
361
+ return float_to_words(float(raw)) + ' percent'
362
+ return number_to_words(int(raw)) + ' percent'
363
+
364
+ return _RE_PERCENT.sub(_replace, text)
365
+
366
+
367
+ def expand_newlines(text: str) -> str:
368
+ """Change newlines/returns to a period and space for TTS pausing."""
369
+ return _RE_NEWLINE.sub('. ', text)
370
+
371
+
372
+ def expand_tilde(text: str) -> str:
373
+ """Change ~ to 'about'."""
374
+ return _RE_TILDE.sub('about ', text)
375
+
376
+
377
+ def expand_currency(text: str) -> str:
378
+ """
379
+ Expand currency amounts, including optional scale suffixes.
380
+
381
+ Examples:
382
+ "$100" → "one hundred dollars"
383
+ "€1,200.50" → "twelve hundred euros and fifty cents"
384
+ "£9.99" → "nine pounds and ninety-nine cents"
385
+ "$85K" → "eighty five thousand dollars"
386
+ "$2.5M" → "two point five million dollars"
387
+ """
388
+
389
+ def _replace(m: re.Match) -> str:
390
+ symbol = m.group(1)
391
+ raw = m.group(2).replace(',', '')
392
+ scale_suffix = m.group(3)
393
+ unit = _CURRENCY_SYMBOLS.get(symbol, '')
394
+
395
+ # Handle Scaled Currency ($17.5 billion or $17.5B)
396
+ if scale_suffix:
397
+ # Normalize suffix (e.g., 'B' or 'billion' -> 'billion')
398
+ scale_word = _CURRENCY_SCALE_MAP.get(scale_suffix.upper(), scale_suffix.lower())
399
+ num = float_to_words(raw) if '.' in raw else number_to_words(int(raw))
400
+ return f'{num} {scale_word} {unit}{"s" if unit else ""}'.strip()
401
+
402
+ # Handle Standard Currency ($17.50)
403
+ if '.' in raw:
404
+ int_part, dec_part = raw.split('.', 1)
405
+ dec_val = int(dec_part[:2].ljust(2, '0'))
406
+ int_words = number_to_words(int(int_part))
407
+ result = f'{int_words} {unit}s' if unit else int_words
408
+ if dec_val:
409
+ cents = number_to_words(dec_val)
410
+ result += f' and {cents} cent{"s" if dec_val != 1 else ""}'
411
+ else:
412
+ val = int(raw)
413
+ words = number_to_words(val)
414
+ result = f'{words} {unit}{"s" if val != 1 and unit else ""}' if unit else words
415
+ return result
416
+
417
+ return _RE_CURRENCY.sub(_replace, text)
418
+
419
+
420
+ def expand_time(text: str) -> str:
421
+ """
422
+ Expand time expressions.
423
+
424
+ Examples:
425
+ "3:30pm" → "three thirty pm"
426
+ "14:00" → "fourteen hundred"
427
+ "9:05 AM" → "nine oh five am"
428
+ "12:00pm" → "twelve pm"
429
+ """
430
+
431
+ def _replace(m: re.Match) -> str:
432
+ h = int(m.group(1))
433
+ mins = int(m.group(2))
434
+ suffix = (' ' + m.group(4).lower()) if m.group(4) else ''
435
+ h_words = number_to_words(h)
436
+ if mins == 0:
437
+ return f'{h_words} hundred{suffix}' if not m.group(4) else f'{h_words}{suffix}'
438
+ elif mins < 10:
439
+ return f'{h_words} oh {number_to_words(mins)}{suffix}'
440
+ else:
441
+ return f'{h_words} {number_to_words(mins)}{suffix}'
442
+
443
+ return _RE_TIME.sub(_replace, text)
444
+
445
+
446
+ def expand_ranges(text: str) -> str:
447
+ """
448
+ Expand numeric ranges.
449
+
450
+ Examples:
451
+ "10-20 items" → "ten to twenty items"
452
+ "pages 100-200" → "pages one hundred to two hundred"
453
+ "2020-2024" → "twenty twenty to twenty twenty-four"
454
+ """
455
+
456
+ def _replace(m: re.Match) -> str:
457
+ lo = number_to_words(int(m.group(1)))
458
+ hi = number_to_words(int(m.group(2)))
459
+ return f'{lo} to {hi}'
460
+
461
+ return _RE_RANGE.sub(_replace, text)
462
+
463
+
464
+ def expand_model_names(text: str) -> str:
465
+ """
466
+ Normalise version/model names that use letter-hyphen-number patterns,
467
+ so the number is not misread as negative.
468
+
469
+ Examples:
470
+ "GPT-3" → "GPT 3"
471
+ "gpt-3.5" → "gpt 3.5"
472
+ "GPL-3" → "GPL 3"
473
+ "Python-3.10"→ "Python 3.10"
474
+ "v2.0" stays as "v2.0" (no hyphen — handled by number replacement)
475
+ "IPv6" stays as "IPv6"
476
+ """
477
+ return _RE_MODEL_VER.sub(lambda m: f'{m.group(1)} {m.group(2)}', text)
478
+
479
+
480
+ def expand_units(text: str) -> str:
481
+ """
482
+ Expand common measurement units glued to numbers.
483
+
484
+ Examples:
485
+ "100km" → "one hundred kilometers"
486
+ "50kg" → "fifty kilograms"
487
+ "25°C" → "twenty-five degrees Celsius"
488
+ "5GB" → "five gigabytes"
489
+ """
490
+ _unit_map = {
491
+ 'km': 'kilometers',
492
+ 'kg': 'kilograms',
493
+ 'mg': 'milligrams',
494
+ 'ml': 'milliliters',
495
+ 'gb': 'gigabytes',
496
+ 'mb': 'megabytes',
497
+ 'kb': 'kilobytes',
498
+ 'tb': 'terabytes',
499
+ 'hz': 'hertz',
500
+ 'khz': 'kilohertz',
501
+ 'mhz': 'megahertz',
502
+ 'ghz': 'gigahertz',
503
+ 'mph': 'miles per hour',
504
+ 'kph': 'kilometers per hour',
505
+ 'ms': 'milliseconds',
506
+ 'ns': 'nanoseconds',
507
+ 'µs': 'microseconds',
508
+ '°c': 'degrees Celsius',
509
+ 'c°': 'degrees Celsius',
510
+ '°f': 'degrees Fahrenheit',
511
+ 'f°': 'degrees Fahrenheit',
512
+ }
513
+
514
+ def _replace(m: re.Match) -> str:
515
+ raw = m.group(1)
516
+ unit = m.group(2).lower()
517
+ expanded = _unit_map.get(unit, m.group(2))
518
+ num = float_to_words(float(raw)) if '.' in raw else number_to_words(int(raw))
519
+ return f'{num} {expanded}'
520
+
521
+ return _RE_UNIT.sub(_replace, text)
522
+
523
+
524
+ def expand_roman_numerals(text: str, context_words: bool = True) -> str:
525
+ """
526
+ Expand Roman numerals that appear as standalone tokens (optionally
527
+ only when preceded by a title-like word to avoid false positives).
528
+
529
+ Examples:
530
+ "World War II" → "World War two"
531
+ "Chapter IV" → "Chapter four"
532
+ "Louis XIV" → "Louis fourteen"
533
+ "mix I with V" → left unchanged (ambiguous single letters)
534
+ """
535
+ _TITLE_WORDS = re.compile(
536
+ r'\b(war|chapter|part|volume|act|scene|book|section|article|'
537
+ r'king|queen|pope|louis|henry|edward|george|william|james|'
538
+ r'phase|round|level|stage|class|type|version|episode|season)\b',
539
+ re.IGNORECASE,
540
+ )
541
+
542
+ def _replace(m: re.Match) -> str:
543
+ roman = m.group(0)
544
+ if not roman.strip():
545
+ return roman
546
+ # Skip single ambiguous letters (I, V, X) unless context present
547
+ if len(roman) == 1 and roman in 'IVX':
548
+ # Only expand if preceded by a title word
549
+ start = m.start()
550
+ preceding = text[max(0, start - 30) : start]
551
+ if not _TITLE_WORDS.search(preceding):
552
+ return roman
553
+ try:
554
+ val = roman_to_int(roman)
555
+ if val == 0:
556
+ return roman
557
+ return number_to_words(val)
558
+ except Exception:
559
+ return roman
560
+
561
+ return _RE_ROMAN.sub(_replace, text)
562
+
563
+
564
+ def normalize_leading_decimals(text: str) -> str:
565
+ """
566
+ Normalise bare leading-decimal floats so the number pipeline handles them.
567
+
568
+ Examples:
569
+ ".5 teaspoons" → "0.5 teaspoons"
570
+ "-.25 adjustment" → "-0.25 adjustment"
571
+ """
572
+ # Handle -.5 → -0.5 and .5 → 0.5
573
+ text = re.sub(r'(?<!\d)(-)\.([\d])', r'\g<1>0.\2', text)
574
+ return _RE_LEAD_DEC.sub(r'0.\1', text)
575
+
576
+
577
+ def expand_scientific_notation(text: str) -> str:
578
+ """
579
+ Expand scientific-notation numbers to spoken form.
580
+
581
+ Examples:
582
+ "1e-4" → "one times ten to the negative four"
583
+ "2.5e10" → "two point five times ten to the ten"
584
+ "6.022E23"→ "six point zero two two times ten to the twenty three"
585
+ """
586
+
587
+ def _replace(m: re.Match) -> str:
588
+ coeff_raw = m.group(1)
589
+ exp = int(m.group(2))
590
+ coeff_words = (
591
+ float_to_words(coeff_raw) if '.' in coeff_raw else number_to_words(int(coeff_raw))
592
+ )
593
+ exp_words = number_to_words(abs(exp))
594
+ sign = 'negative ' if exp < 0 else ''
595
+ return f'{coeff_words} times ten to the {sign}{exp_words}'
596
+
597
+ return _RE_SCI.sub(_replace, text)
598
+
599
+
600
+ def expand_scale_suffixes(text: str) -> str:
601
+ """
602
+ Expand standalone uppercase scale suffixes attached to numbers.
603
+
604
+ Examples:
605
+ "7B parameters" → "seven billion parameters"
606
+ "340M model" → "three hundred forty million model"
607
+ "1.5K salary" → "one point five thousand salary"
608
+ "$100K budget" → "$100K budget" (currency handled upstream)
609
+ """
610
+ _map = {'K': 'thousand', 'M': 'million', 'B': 'billion', 'T': 'trillion'}
611
+
612
+ def _replace(m: re.Match) -> str:
613
+ raw = m.group(1)
614
+ suffix = m.group(2)
615
+ scale_word = _map.get(suffix, suffix)
616
+ num = float_to_words(raw) if '.' in raw else number_to_words(int(raw))
617
+ return f'{num} {scale_word}'
618
+
619
+ return _RE_SCALE.sub(_replace, text)
620
+
621
+
622
+ def expand_fractions(text: str) -> str:
623
+ """
624
+ Expand simple numeric fractions.
625
+
626
+ Examples:
627
+ "1/2 cup" → "one half cup"
628
+ "3/4 mile" → "three quarters mile"
629
+ "2/3 done" → "two thirds done"
630
+ "5/8 inch" → "five eighths inch"
631
+ """
632
+
633
+ def _replace(m: re.Match) -> str:
634
+ num = int(m.group(1))
635
+ den = int(m.group(2))
636
+ if den == 0:
637
+ return m.group()
638
+ num_words = number_to_words(num)
639
+ if den == 2:
640
+ denom_word = 'half' if num == 1 else 'halves'
641
+ elif den == 4:
642
+ denom_word = 'quarter' if num == 1 else 'quarters'
643
+ else:
644
+ denom_word = _ordinal_suffix(den)
645
+ if num != 1:
646
+ denom_word += 's'
647
+ return f'{num_words} {denom_word}'
648
+
649
+ return _RE_FRACTION.sub(_replace, text)
650
+
651
+
652
+ def expand_decades(text: str) -> str:
653
+ """
654
+ Expand decade expressions to words.
655
+
656
+ Examples:
657
+ "the 80s" → "the eighties"
658
+ "the 1980s" → "the nineteen eighties"
659
+ "the 2020s" → "the twenty twenties"
660
+ "'90s music" → "nineties music"
661
+ """
662
+ _decade_map = {
663
+ 0: 'hundreds',
664
+ 1: 'tens',
665
+ 2: 'twenties',
666
+ 3: 'thirties',
667
+ 4: 'forties',
668
+ 5: 'fifties',
669
+ 6: 'sixties',
670
+ 7: 'seventies',
671
+ 8: 'eighties',
672
+ 9: 'nineties',
673
+ }
674
+
675
+ def _replace(m: re.Match) -> str:
676
+ base = int(m.group(1)) # e.g. 8 for "80s", 198 for "1980s"
677
+ decade_digit = base % 10
678
+ decade_word = _decade_map.get(decade_digit, '')
679
+ if base < 10:
680
+ return decade_word
681
+ century_part = base // 10 # e.g. 19 for 198
682
+ return f'{number_to_words(century_part)} {decade_word}'
683
+
684
+ return _RE_DECADE.sub(_replace, text)
685
+
686
+
687
+ def expand_ip_addresses(text: str) -> str:
688
+ """
689
+ Expand IPv4 addresses to spoken digits per octet.
690
+
691
+ Examples:
692
+ "192.168.1.1" → "one nine two dot one six eight dot one dot one"
693
+ "10.0.0.1" → "one zero dot zero dot zero dot one"
694
+ """
695
+ _d = {
696
+ '0': 'zero',
697
+ '1': 'one',
698
+ '2': 'two',
699
+ '3': 'three',
700
+ '4': 'four',
701
+ '5': 'five',
702
+ '6': 'six',
703
+ '7': 'seven',
704
+ '8': 'eight',
705
+ '9': 'nine',
706
+ }
707
+
708
+ def _octet(s: str) -> str:
709
+ return ' '.join(_d[c] for c in s)
710
+
711
+ def _replace(m: re.Match) -> str:
712
+ return ' dot '.join(_octet(g) for g in m.groups())
713
+
714
+ return re.sub(r'\b(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\b', _replace, text)
715
+
716
+
717
+ def expand_phone_numbers(text: str) -> str:
718
+ """
719
+ Expand US phone numbers to spoken digits before range expansion claims the hyphens.
720
+
721
+ Examples:
722
+ "555-1234" → "five five five one two three four"
723
+ "555-123-4567" → "five five five one two three four five six seven"
724
+ "1-800-555-0199" → "one eight zero zero five five five zero one nine nine"
725
+ """
726
+ _d = {
727
+ '0': 'zero',
728
+ '1': 'one',
729
+ '2': 'two',
730
+ '3': 'three',
731
+ '4': 'four',
732
+ '5': 'five',
733
+ '6': 'six',
734
+ '7': 'seven',
735
+ '8': 'eight',
736
+ '9': 'nine',
737
+ }
738
+
739
+ def _digits(s: str) -> str:
740
+ return ' '.join(_d[c] for c in s)
741
+
742
+ def _join(*groups) -> str:
743
+ return ' '.join(_digits(g) for g in groups)
744
+
745
+ # Match longest pattern first to avoid partial matches
746
+ # 11-digit: 1-800-555-0199
747
+ text = re.sub(
748
+ r'(?<!\d-)(?<!\d)\b(\d{1,2})-(\d{3})-(\d{3})-(\d{4})\b(?!-\d)',
749
+ lambda m: _join(*m.groups()),
750
+ text,
751
+ )
752
+ # 10-digit: 555-123-4567
753
+ text = re.sub(
754
+ r'(?<!\d-)(?<!\d)\b(\d{3})-(\d{3})-(\d{4})\b(?!-\d)', lambda m: _join(*m.groups()), text
755
+ )
756
+ # 7-digit local: 555-1234 (not preceded or followed by digit-hyphen to avoid sub-matching)
757
+ text = re.sub(r'(?<!\d-)\b(\d{3})-(\d{4})\b(?!-\d)', lambda m: _join(*m.groups()), text)
758
+ return text
759
+
760
+
761
+ def expand_months(text: str) -> str:
762
+ """
763
+ Expands Jan, Feb, etc. to January, February.
764
+ Only triggers if the abbreviation is likely a date.
765
+ """
766
+
767
+ def _replace(m: re.Match) -> str:
768
+ return _MONTH_MAP.get(m.group(1), m.group(1))
769
+
770
+ # 1. Standard abbreviations
771
+ text = _RE_MONTHS.sub(_replace, text)
772
+
773
+ # 2. May (Special case: only if followed by a digit)
774
+ text = _RE_MAY.sub('May', text) # Essentially just ensuring it's treated as a word
775
+
776
+ return text
777
+
778
+
779
+ # ─────────────────────────────────────────────
780
+ # Core preprocessing functions
781
+ # ─────────────────────────────────────────────
782
+
783
+
784
+ def replace_numbers(text: str, replace_floats: bool = True) -> str:
785
+ """
786
+ Replace all numeric tokens with their word equivalents.
787
+
788
+ Examples:
789
+ "There are 1200 students" → "There are twelve hundred students"
790
+ "Pi is 3.14" → "Pi is three point one four"
791
+ "gpt-3 rocks" → "gpt-3 rocks" (hyphen not treated as minus)
792
+ """
793
+
794
+ def _replace(m: re.Match) -> str:
795
+ raw = m.group().replace(',', '')
796
+ try:
797
+ if '.' in raw and replace_floats:
798
+ # Pass raw string so trailing zeros are preserved ("1.50" → "one point five zero")
799
+ return float_to_words(raw)
800
+ else:
801
+ return number_to_words(int(float(raw)))
802
+ except (ValueError, OverflowError):
803
+ return m.group()
804
+
805
+ return _RE_NUMBER.sub(_replace, text)
806
+
807
+
808
+ def to_lowercase(text: str) -> str:
809
+ """Convert text to lowercase."""
810
+ return text.lower()
811
+
812
+
813
+ def remove_urls(text: str, replacement: str = '') -> str:
814
+ """Remove URLs from text."""
815
+ return _RE_URL.sub(replacement, text).strip()
816
+
817
+
818
+ def remove_emails(text: str, replacement: str = '') -> str:
819
+ """Remove email addresses from text."""
820
+ return _RE_EMAIL.sub(replacement, text).strip()
821
+
822
+
823
+ def remove_html_tags(text: str) -> str:
824
+ """Strip HTML tags from text."""
825
+ return _RE_HTML.sub(' ', text)
826
+
827
+
828
+ def remove_hashtags(text: str, replacement: str = '') -> str:
829
+ """Remove hashtags (e.g. #NLP) from text."""
830
+ return _RE_HASHTAG.sub(replacement, text)
831
+
832
+
833
+ def remove_mentions(text: str, replacement: str = '') -> str:
834
+ """Remove @mentions from text."""
835
+ return _RE_MENTION.sub(replacement, text)
836
+
837
+
838
+ def remove_punctuation(text: str) -> str:
839
+ """Remove all punctuation characters."""
840
+ return _RE_PUNCT.sub(' ', text)
841
+
842
+
843
+ def remove_extra_whitespace(text: str) -> str:
844
+ """Collapse multiple whitespace characters into a single space and strip ends."""
845
+ return _RE_SPACES.sub(' ', text).strip()
846
+
847
+
848
+ def normalize_unicode(text: str, form: str = 'NFC') -> str:
849
+ """Normalize unicode characters (NFC, NFD, NFKC, or NFKD)."""
850
+ return unicodedata.normalize(form, text)
851
+
852
+
853
+ def remove_accents(text: str) -> str:
854
+ """Remove diacritical marks (accents) from characters."""
855
+ nfkd = unicodedata.normalize('NFD', text)
856
+ return ''.join(c for c in nfkd if unicodedata.category(c) != 'Mn')
857
+
858
+
859
+ def expand_contractions(text: str) -> str:
860
+ """
861
+ Expand common English contractions.
862
+
863
+ Examples:
864
+ "don't" → "do not"
865
+ "they're" → "they are"
866
+ "I've" → "I have"
867
+ """
868
+ contractions = {
869
+ r"\bcan't\b": 'cannot',
870
+ r"\bwon't\b": 'will not',
871
+ r"\bshan't\b": 'shall not',
872
+ r"\bain't\b": 'is not',
873
+ r"\blet's\b": 'let us',
874
+ r"\b(\w+)n't\b": r'\1 not',
875
+ r"\b(\w+)'re\b": r'\1 are',
876
+ r"\b(\w+)'ve\b": r'\1 have',
877
+ r"\b(\w+)'ll\b": r'\1 will',
878
+ r"\b(\w+)'d\b": r'\1 would',
879
+ r"\b(\w+)'m\b": r'\1 am',
880
+ r"\bit's\b": 'it is',
881
+ }
882
+ for pattern, replacement in contractions.items():
883
+ text = re.sub(pattern, replacement, text, flags=re.IGNORECASE)
884
+ return text
885
+
886
+
887
+ def remove_stopwords(text: str, stopwords: set | None = None) -> str:
888
+ """
889
+ Remove stopwords from text.
890
+
891
+ Args:
892
+ stopwords: Set of words to remove. Uses a built-in English set if None.
893
+ """
894
+ if stopwords is None:
895
+ stopwords = {
896
+ 'a',
897
+ 'an',
898
+ 'the',
899
+ 'and',
900
+ 'or',
901
+ 'but',
902
+ 'in',
903
+ 'on',
904
+ 'at',
905
+ 'to',
906
+ 'for',
907
+ 'of',
908
+ 'with',
909
+ 'by',
910
+ 'from',
911
+ 'is',
912
+ 'was',
913
+ 'are',
914
+ 'were',
915
+ 'be',
916
+ 'been',
917
+ 'being',
918
+ 'have',
919
+ 'has',
920
+ 'had',
921
+ 'do',
922
+ 'does',
923
+ 'did',
924
+ 'will',
925
+ 'would',
926
+ 'could',
927
+ 'should',
928
+ 'may',
929
+ 'might',
930
+ 'this',
931
+ 'that',
932
+ 'these',
933
+ 'those',
934
+ 'it',
935
+ 'its',
936
+ 'i',
937
+ 'me',
938
+ 'my',
939
+ 'we',
940
+ 'our',
941
+ 'you',
942
+ 'your',
943
+ 'he',
944
+ 'she',
945
+ 'him',
946
+ 'her',
947
+ 'they',
948
+ 'them',
949
+ 'their',
950
+ }
951
+ tokens = text.split()
952
+ return ' '.join(t for t in tokens if t.lower() not in stopwords)
953
+
954
+
955
+ # ─────────────────────────────────────────────
956
+ # Pipeline helper
957
+ # ─────────────────────────────────────────────
958
+
959
+
960
+ class TextPreprocessor:
961
+ """
962
+ Configurable preprocessing pipeline.
963
+
964
+ Usage:
965
+ pp = TextPreprocessor(
966
+ lowercase=True,
967
+ replace_numbers=True,
968
+ remove_urls=True,
969
+ remove_html=True,
970
+ remove_punctuation=True,
971
+ )
972
+ clean = pp("GPT-3 costs $0.002 per token — 50% cheaper than before!")
973
+ # → "gpt three costs zero dollars and zero point two cents per token fifty percent cheaper than before"
974
+ """
975
+
976
+ def __init__(
977
+ self,
978
+ lowercase: bool = True,
979
+ replace_numbers: bool = True,
980
+ replace_floats: bool = True,
981
+ expand_newlines: bool = True,
982
+ expand_tilde: bool = True,
983
+ expand_abbreviations: bool = True,
984
+ expand_symbols: bool = True,
985
+ expand_contractions: bool = True,
986
+ expand_model_names: bool = True,
987
+ expand_ordinals: bool = True,
988
+ expand_percentages: bool = True,
989
+ expand_currency: bool = True,
990
+ expand_time: bool = True,
991
+ expand_ranges: bool = True,
992
+ expand_units: bool = True,
993
+ expand_scale_suffixes: bool = True,
994
+ expand_scientific_notation: bool = True,
995
+ expand_fractions: bool = True,
996
+ expand_decades: bool = True,
997
+ expand_phone_numbers: bool = True,
998
+ expand_ip_addresses: bool = True,
999
+ normalize_leading_decimals: bool = True,
1000
+ expand_roman_numerals: bool = False,
1001
+ remove_urls: bool = True,
1002
+ remove_emails: bool = True,
1003
+ remove_html: bool = True,
1004
+ remove_hashtags: bool = False,
1005
+ remove_mentions: bool = False,
1006
+ remove_punctuation: bool = True,
1007
+ remove_stopwords: bool = False,
1008
+ stopwords: set | None = None,
1009
+ normalize_unicode: bool = True,
1010
+ remove_accents: bool = False,
1011
+ remove_extra_whitespace: bool = True,
1012
+ ):
1013
+ self.config = {k: v for k, v in locals().items() if k != 'self'}
1014
+ self._stopwords = stopwords
1015
+
1016
+ def __call__(self, text: str) -> str:
1017
+ return self.process(text)
1018
+
1019
+ def process(self, text: str) -> str:
1020
+ cfg = self.config
1021
+ if cfg.get('expand_abbreviations'):
1022
+ text = expand_abbreviations(text)
1023
+ text = expand_months(text)
1024
+ if cfg.get('expand_newlines'):
1025
+ text = expand_newlines(text)
1026
+ if cfg.get('expand_symbols'):
1027
+ text = expand_symbols(text)
1028
+ if cfg.get('expand_tilde'):
1029
+ text = expand_tilde(text)
1030
+ if cfg['normalize_unicode']:
1031
+ text = normalize_unicode(text)
1032
+ if cfg['remove_html']:
1033
+ text = remove_html_tags(text)
1034
+ if cfg['remove_urls']:
1035
+ text = remove_urls(text)
1036
+ if cfg['remove_emails']:
1037
+ text = remove_emails(text)
1038
+ if cfg['remove_hashtags']:
1039
+ text = remove_hashtags(text)
1040
+ if cfg['remove_mentions']:
1041
+ text = remove_mentions(text)
1042
+ if cfg['expand_contractions']:
1043
+ text = expand_contractions(text)
1044
+ # IP addresses before normalize_leading_decimals (IPs contain dots before digits)
1045
+ if cfg['expand_ip_addresses']:
1046
+ text = expand_ip_addresses(text)
1047
+ # Normalise bare leading decimals early so downstream regexes see "0.5" not ".5"
1048
+ if cfg['normalize_leading_decimals']:
1049
+ text = normalize_leading_decimals(text)
1050
+ # Expand special forms before generic number replacement
1051
+ if cfg['expand_currency']:
1052
+ text = expand_currency(text)
1053
+ if cfg['expand_percentages']:
1054
+ text = expand_percentages(text)
1055
+ # Scientific notation before model-name expansion (e.g. "1e-4" contains "e-4")
1056
+ if cfg['expand_scientific_notation']:
1057
+ text = expand_scientific_notation(text)
1058
+ if cfg['expand_time']:
1059
+ text = expand_time(text)
1060
+ if cfg['expand_ordinals']:
1061
+ text = expand_ordinals(text)
1062
+ if cfg['expand_units']:
1063
+ text = expand_units(text)
1064
+ # Scale suffixes after units (units handles "MB"/"GB"; this handles bare "B"/"M")
1065
+ if cfg['expand_scale_suffixes']:
1066
+ text = expand_scale_suffixes(text)
1067
+ if cfg['expand_fractions']:
1068
+ text = expand_fractions(text)
1069
+ if cfg['expand_decades']:
1070
+ text = expand_decades(text)
1071
+ # Phone numbers before ranges, otherwise NNN-NNNN is treated as a range
1072
+ if cfg['expand_phone_numbers']:
1073
+ text = expand_phone_numbers(text)
1074
+ if cfg['expand_ranges']:
1075
+ text = expand_ranges(text)
1076
+ if cfg['expand_model_names']:
1077
+ text = expand_model_names(text)
1078
+ if cfg['expand_roman_numerals']:
1079
+ text = expand_roman_numerals(text)
1080
+ if cfg['replace_numbers']:
1081
+ text = replace_numbers(text, replace_floats=cfg['replace_floats'])
1082
+ if cfg['remove_accents']:
1083
+ text = remove_accents(text)
1084
+ if cfg['remove_punctuation']:
1085
+ text = remove_punctuation(text)
1086
+ if cfg['lowercase']:
1087
+ text = to_lowercase(text)
1088
+ if cfg['remove_stopwords']:
1089
+ text = remove_stopwords(text, self._stopwords)
1090
+ if cfg['remove_extra_whitespace']:
1091
+ text = remove_extra_whitespace(text)
1092
+
1093
+ return text
pyproject.toml CHANGED
@@ -4,12 +4,12 @@ license = {text = "MIT"}
4
  name = "pocket-tts-openai-server"
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
- version = "2.3.0"
8
 
9
  dependencies = [
10
  "flask>=3.0.0",
11
  "waitress>=3.0.0",
12
- "pocket-tts>=1.1.0",
13
  "torch>=2.0.0,<=2.8.0",
14
  "torchaudio>=2.0.0,<=2.8.0",
15
  "scipy>=1.10.0",
 
4
  name = "pocket-tts-openai-server"
5
  readme = "README.md"
6
  requires-python = ">=3.10"
7
+ version = "2.4.0"
8
 
9
  dependencies = [
10
  "flask>=3.0.0",
11
  "waitress>=3.0.0",
12
+ "pocket-tts>=1.1.1",
13
  "torch>=2.0.0,<=2.8.0",
14
  "torchaudio>=2.0.0,<=2.8.0",
15
  "scipy>=1.10.0",
run_pocket_tts_server.bat CHANGED
@@ -62,6 +62,12 @@ set "STREAM_ARG=--stream"
62
  set /p "INPUT_STREAM=Enable Streaming? (Y/N) [Y]: "
63
  if /i "%INPUT_STREAM%"=="N" set "STREAM_ARG="
64
 
 
 
 
 
 
 
65
  echo.
66
  echo ========================================================
67
  echo Starting Pocket TTS Server...
@@ -70,11 +76,12 @@ echo Port: %PORT%
70
  if defined MODEL_PATH echo Model: %MODEL_PATH%
71
  if defined VOICES_DIR echo Voices: %VOICES_DIR%
72
  if defined STREAM_ARG echo Streaming: Enabled
 
73
  echo ========================================================
74
  echo.
75
 
76
- :: 7. Run Command
77
- python server.py --host %HOST% --port %PORT% %MODEL_PATH% %VOICES_DIR_ARG% %STREAM_ARG%
78
 
79
  if %ERRORLEVEL% NEQ 0 (
80
  echo.
 
62
  set /p "INPUT_STREAM=Enable Streaming? (Y/N) [Y]: "
63
  if /i "%INPUT_STREAM%"=="N" set "STREAM_ARG="
64
 
65
+ :: 7. Text Preprocessing Default
66
+ :: Defaults to ON. Only unsets if the user types 'N'.
67
+ set "TEXT_PREPROCESS_ARG=--text-preprocess"
68
+ set /p "INPUT_PREPROCESS=Enable Text Preprocessing? (Y/N) [Y]: "
69
+ if /i "%INPUT_PREPROCESS%"=="N" set "TEXT_PREPROCESS_ARG="
70
+
71
  echo.
72
  echo ========================================================
73
  echo Starting Pocket TTS Server...
 
76
  if defined MODEL_PATH echo Model: %MODEL_PATH%
77
  if defined VOICES_DIR echo Voices: %VOICES_DIR%
78
  if defined STREAM_ARG echo Streaming: Enabled
79
+ if defined TEXT_PREPROCESS_ARG echo Text Preprocessing: Enabled
80
  echo ========================================================
81
  echo.
82
 
83
+ :: 8. Run Command
84
+ python server.py --host %HOST% --port %PORT% %MODEL_PATH% %VOICES_DIR_ARG% %STREAM_ARG% %TEXT_PREPROCESS_ARG%
85
 
86
  if %ERRORLEVEL% NEQ 0 (
87
  echo.
run_pocket_tts_server_exe.bat CHANGED
@@ -55,6 +55,12 @@ set "STREAM_ARG=--stream"
55
  set /p "INPUT_STREAM=Enable Streaming? (Y/N) [Y]: "
56
  if /i "%INPUT_STREAM%"=="N" set "STREAM_ARG="
57
 
 
 
 
 
 
 
58
  echo.
59
  echo ========================================================
60
  echo Starting Pocket TTS Server (EXE)...
@@ -63,12 +69,13 @@ echo Port: %PORT%
63
  if defined MODEL_PATH echo Model: %MODEL_PATH%
64
  if defined VOICES_DIR (echo Voices: %VOICES_DIR%) else (echo Voices: Default/None)
65
  if defined STREAM_ARG (echo Streaming: Enabled) else (echo Streaming: Disabled)
 
66
  echo ========================================================
67
  echo.
68
 
69
- :: 6. Run Command
70
  if exist "%~dp0PocketTTS-Server.exe" (
71
- "%~dp0PocketTTS-Server.exe" --host %HOST% --port %PORT% %MODEL_PATH% %VOICES_DIR% %STREAM_ARG%
72
  ) else (
73
  echo [ERROR] PocketTTS-Server.exe not found in the current directory.
74
  echo Please make sure the executable is located in: %~dp0
 
55
  set /p "INPUT_STREAM=Enable Streaming? (Y/N) [Y]: "
56
  if /i "%INPUT_STREAM%"=="N" set "STREAM_ARG="
57
 
58
+ :: 6. Text Preprocessing Default
59
+ :: Defaults to ON. Only unsets if the user types 'N'.
60
+ set "TEXT_PREPROCESS_ARG=--text-preprocess"
61
+ set /p "INPUT_PREPROCESS=Enable Text Preprocessing? (Y/N) [Y]: "
62
+ if /i "%INPUT_PREPROCESS%"=="N" set "TEXT_PREPROCESS_ARG="
63
+
64
  echo.
65
  echo ========================================================
66
  echo Starting Pocket TTS Server (EXE)...
 
69
  if defined MODEL_PATH echo Model: %MODEL_PATH%
70
  if defined VOICES_DIR (echo Voices: %VOICES_DIR%) else (echo Voices: Default/None)
71
  if defined STREAM_ARG (echo Streaming: Enabled) else (echo Streaming: Disabled)
72
+ if defined TEXT_PREPROCESS_ARG (echo Text Preprocessing: Enabled) else (echo Text Preprocessing: Disabled)
73
  echo ========================================================
74
  echo.
75
 
76
+ :: 7. Run Command
77
  if exist "%~dp0PocketTTS-Server.exe" (
78
+ "%~dp0PocketTTS-Server.exe" --host %HOST% --port %PORT% %MODEL_PATH% %VOICES_DIR% %STREAM_ARG% %TEXT_PREPROCESS_ARG%
79
  ) else (
80
  echo [ERROR] PocketTTS-Server.exe not found in the current directory.
81
  echo Please make sure the executable is located in: %~dp0
server.py CHANGED
@@ -43,6 +43,7 @@ Environment Variables:
43
  POCKET_TTS_MODEL_PATH Path to model file
44
  POCKET_TTS_VOICES_DIR Path to voices directory
45
  POCKET_TTS_STREAM_DEFAULT Enable streaming by default
 
46
  POCKET_TTS_LOG_DIR Log directory path
47
  """,
48
  )
@@ -73,6 +74,12 @@ Environment Variables:
73
  default=Config.STREAM_DEFAULT,
74
  help='Enable streaming by default for all requests',
75
  )
 
 
 
 
 
 
76
  parser.add_argument(
77
  '--log-level',
78
  type=str,
@@ -93,7 +100,9 @@ def main():
93
  os.environ.setdefault('POCKET_TTS_LOG_LEVEL', args.log_level)
94
 
95
  # Create app
96
- app = create_app({'STREAM_DEFAULT': args.stream})
 
 
97
 
98
  logger = get_logger()
99
 
 
43
  POCKET_TTS_MODEL_PATH Path to model file
44
  POCKET_TTS_VOICES_DIR Path to voices directory
45
  POCKET_TTS_STREAM_DEFAULT Enable streaming by default
46
+ POCKET_TTS_TEXT_PREPROCESS_DEFAULT Enable text preprocessing by default
47
  POCKET_TTS_LOG_DIR Log directory path
48
  """,
49
  )
 
74
  default=Config.STREAM_DEFAULT,
75
  help='Enable streaming by default for all requests',
76
  )
77
+ parser.add_argument(
78
+ '--text-preprocess',
79
+ action='store_true',
80
+ default=Config.TEXT_PREPROCESS_DEFAULT,
81
+ help='Enable text preprocessing for all requests',
82
+ )
83
  parser.add_argument(
84
  '--log-level',
85
  type=str,
 
100
  os.environ.setdefault('POCKET_TTS_LOG_LEVEL', args.log_level)
101
 
102
  # Create app
103
+ app = create_app(
104
+ {'STREAM_DEFAULT': args.stream, 'TEXT_PREPROCESS_DEFAULT': args.text_preprocess}
105
+ )
106
 
107
  logger = get_logger()
108