normt2s 0.1.0__tar.gz

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.
normt2s-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christian Siegel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
normt2s-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,392 @@
1
+ Metadata-Version: 2.4
2
+ Name: normt2s
3
+ Version: 0.1.0
4
+ Summary: Lightweight, dependency-free text normalization (numbers, units, abbreviations) for TTS.
5
+ Author: Christian Siegel
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/c-siegel/normt2s
8
+ Project-URL: Repository, https://github.com/c-siegel/normt2s
9
+ Project-URL: Issues, https://github.com/c-siegel/normt2s/issues
10
+ Keywords: tts,text-normalization,num2words,speech
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Text Processing :: Linguistic
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # normt2s
28
+
29
+ **Make written text speakable.** `normt2s` turns numbers, units, abbreviations, dates and math into
30
+ the words a text-to-speech engine should say, so `3.5 km/h` is not read as "three dot five k m
31
+ slash h".
32
+
33
+ - **English and German**
34
+ - **No dependencies**, pure Python 3.9+, MIT licensed
35
+ - **Fast**: about 90 µs for a typical sentence
36
+ - **Streaming-ready**: works on LLM output as it arrives, token by token
37
+ - **Easy to extend**: add words with one line of code or a small JSON file
38
+
39
+ ```python
40
+ from normt2s import normalize
41
+
42
+ normalize("It costs $3.50 for 2 kg, e.g. in 1984.")
43
+ # 'It costs three dollars and fifty cents for two kilograms, for example in nineteen eighty-four.'
44
+
45
+ normalize("Der Zug fährt 120 km/h, z. B. um 14:30.", lang="de")
46
+ # 'Der Zug fährt einhundertzwanzig Kilometer pro Stunde, zum Beispiel um vierzehn Uhr dreißig.'
47
+ ```
48
+
49
+ ## Contents
50
+
51
+ 1. [Install](#install)
52
+ 2. [What gets converted](#what-gets-converted)
53
+ 3. [Adding your own words](#adding-your-own-words)
54
+ 4. [Math (LaTeX)](#math-latex)
55
+ 5. [Streaming LLM output](#streaming-llm-output)
56
+ 6. [Reference: options and API](#reference-options-and-api)
57
+ 7. [How it works](#how-it-works)
58
+ 8. [Adding a language](#adding-a-language)
59
+ 9. [Performance](#performance)
60
+ 10. [Known limitations](#known-limitations)
61
+ 11. [Development](#development)
62
+
63
+ ## Install
64
+
65
+ `normt2s` is available on [PyPI](https://pypi.org/project/normt2s/):
66
+
67
+ ```
68
+ pip install normt2s
69
+ ```
70
+
71
+ To work on the library itself, install from a clone of the repository instead:
72
+
73
+ ```
74
+ pip install -e .
75
+ ```
76
+
77
+ ## What gets converted
78
+
79
+ Every row is real output from the library.
80
+
81
+ ### English
82
+
83
+ | Category | Input | Spoken |
84
+ |---|---|---|
85
+ | Numbers | `1,234 points` | one thousand two hundred thirty-four points |
86
+ | | `3.14`, `-5`, `007` | three point one four, minus five, zero zero seven |
87
+ | | `1st`, `23rd` | first, twenty-third |
88
+ | | `50%`, `1/2 cup` | fifty percent, one half cup |
89
+ | Years | `in 1984`, `the 1990s` | in nineteen eighty-four, the nineteen nineties |
90
+ | | `I have 1984 apples` | I have one thousand nine hundred eighty-four apples |
91
+ | Time | `3:45 pm`, `at 14:30` | three forty-five p m, at fourteen thirty |
92
+ | Dates | `March 3rd, 2024`, `2024-03-15` | March third, twenty twenty-four; March fifteenth, twenty twenty-four |
93
+ | Units | `100 km/h`, `20°C`, `9.81 m/s²`, `1 ft` / `2 ft` | one hundred kilometers per hour, twenty degrees Celsius, nine point eight one meters per second squared, one foot / two feet |
94
+ | Currency | `$3.50`, `€5`, `5 EUR`, `$2.5 million` | three dollars and fifty cents, five euros, five euros, two point five million dollars |
95
+ | Abbreviations | `Dr. Smith, e.g. ...`, `etc.` | Doctor Smith, for example ..., et cetera |
96
+ | Math symbols | `2 + 3 = 5`, `x²`, `a ≤ b` | two plus three equals five, x squared, a is less than or equal to b |
97
+
98
+ ### German
99
+
100
+ | Category | Input | Spoken |
101
+ |---|---|---|
102
+ | Numbers | `1.234,5` | eintausendzweihundertvierunddreißig Komma fünf |
103
+ | | `1/3`, `3/4` | ein drittel, drei viertel |
104
+ | Dates | `15.03.2024` | fünfzehnter März zweitausendvierundzwanzig |
105
+ | | `am 15.03.2024` | am fünfzehnten März zweitausendvierundzwanzig |
106
+ | | `am 3. Mai`, `im Mai 2024` | am dritten Mai, im Mai zweitausendvierundzwanzig |
107
+ | Time | `um 14:30` | um vierzehn Uhr dreißig |
108
+ | Units | `1 kg`, `1 min`, `21 min` | ein Kilogramm, eine Minute, einundzwanzig Minuten |
109
+ | Currency | `3,50 €`, `5 €` | drei Euro fünfzig Cent, fünf Euro |
110
+ | Abbreviations | `z. B.`, `ca. 5 Mio.` | zum Beispiel, circa fünf Millionen |
111
+
112
+ German dates take the right case ending from the word before them: `am` / `vom` / `zum` / `dem`
113
+ / `den` give *fünfzehnten*, `der` gives *fünfzehnte*, and a date on its own gives *fünfzehnter*.
114
+
115
+ ### What is left alone
116
+
117
+ Code (`` `inline` `` and fenced blocks), URLs, e-mail addresses, HTML tags and Markdown link
118
+ targets are never changed:
119
+
120
+ ```python
121
+ normalize("Run `pip install numpy==1.26` and open https://example.com/v1.2/page")
122
+ # unchanged
123
+ ```
124
+
125
+ Things that only look like numbers are kept too: versions with several dots are read digit by
126
+ digit (`3.2.1` is "three point two point one"), and letters glued to digits (`H2O`, `mp3`) are
127
+ not split.
128
+
129
+ ## Adding your own words
130
+
131
+ There is one rule to remember: **create a `Normalizer`, add words to it, and use that instance.**
132
+
133
+ ```python
134
+ from normt2s import Normalizer, normalize
135
+
136
+ n = Normalizer("en")
137
+ n.add_abbreviation("LOLA", "Lola")
138
+ n.add_unit("kn", "knot") # plural defaults to "knots"
139
+ n.add_unit("ch", "child", "children") # or give the plural yourself
140
+ n.add_currency("CHF", ("franc", "francs"), ("rappen", "rappen"))
141
+
142
+ n.normalize("LOLA sails 12 kn with 2 ch for 5.20 CHF")
143
+ # 'Lola sails twelve knots with two children for five francs and twenty rappen'
144
+
145
+ normalize("LOLA sails 12 kn", n) # the helper also accepts your instance
146
+ ```
147
+
148
+ > **Gotcha:** `normalize(text, "en")` uses a separate, shared instance. Words you add to your own
149
+ > `Normalizer("en")` are **not** visible there. Pass your instance (`normalize(text, n)`), or edit
150
+ > the shared one with `get_normalizer("en")`.
151
+
152
+ Your entries override the built-in ones, and changes apply immediately, even to an instance you
153
+ have already used.
154
+
155
+ ### Which method for what
156
+
157
+ | You want to change | Method | Example |
158
+ |---|---|---|
159
+ | An abbreviation | `add_abbreviation(key, to)` | `("tbh", "to be honest")` |
160
+ | A unit after a number | `add_unit(symbol, singular, plural, gender)` | `("kn", "knot")` |
161
+ | A currency | `add_currency(symbol, major, minor)` | see above |
162
+ | A math symbol such as `=` or `×` | `add_symbol(key, word)` | `("=", "is")` |
163
+ | A LaTeX command | `lexicon.add_latex_command(name, spoken)` | `("R", "the reals")` |
164
+ | A LaTeX function | `lexicon.add_latex_function(name, spoken)` | `("sinc", "sinc")` |
165
+ | A LaTeX sentence template | `lexicon.add_phrase(key, template)` | `("frac", "{a} divided by {b}")` |
166
+ | Remove an entry | `lexicon.remove(kind, key)` | `("abbreviations", "vs.")` |
167
+
168
+ Notes on abbreviations:
169
+
170
+ - Matching is case sensitive, but for lowercase keys a capitalised variant is added for you
171
+ (`e.g.` also gives `E.g.` → "For example"). Pass `capitalized=False` to turn that off.
172
+ - Spaces inside a key are optional when matching: `z. B.` also matches `z.B.`
173
+ - Set `final=True` for abbreviations that can end a sentence (`etc.`), so the full stop is kept
174
+ when a new sentence follows.
175
+
176
+ ### Using a file
177
+
178
+ Put your words in a JSON (or, on Python 3.11+, TOML) file and load it:
179
+
180
+ ```python
181
+ n.load_lexicon("my_terms.json")
182
+ ```
183
+
184
+ ```json
185
+ {
186
+ "abbreviations": {
187
+ "LMS": "learning management system",
188
+ "etc.": {"to": "et cetera", "final": true}
189
+ },
190
+ "units": {"kn": ["knot"], "ch": ["child", "children"]},
191
+ "currencies": {"CHF": {"major": ["franc", "francs"], "minor": ["rappen", "rappen"]}},
192
+ "symbols": {"=": "equals"},
193
+ "latex": {
194
+ "commands": {"R": "the reals"},
195
+ "functions": {"sinc": "sinc"},
196
+ "phrases": {"frac": "{a} divided by {b}"}
197
+ }
198
+ }
199
+ ```
200
+
201
+ The built-in data uses exactly this format: see [src/normt2s/data/en.json](src/normt2s/data/en.json)
202
+ and [de.json](src/normt2s/data/de.json). A unit entry is `[singular, plural, gender]`, where the
203
+ plural and gender are optional and the gender (`"m"`, `"f"`, `"n"`) only matters in German.
204
+
205
+ ## Math (LaTeX)
206
+
207
+ Math in `$...$`, `$$...$$`, `\(...\)` and `\[...\]` is read aloud:
208
+
209
+ ```python
210
+ normalize(r"The area is $\pi r^2$.")
211
+ # 'The area is pi r squared.'
212
+
213
+ normalize(r"Solve $\frac{a+b}{c} = \sqrt{x}$ now")
214
+ # 'Solve the fraction a plus b over c, end fraction equals square root of x now'
215
+
216
+ normalize(r"Es gilt $x \in \mathbb{R}$ und $\sum_{i=1}^{n} i$.", lang="de")
217
+ # 'Es gilt x ist Element von den reellen Zahlen und die Summe von i gleich eins bis n über i.'
218
+ ```
219
+
220
+ Supported: fractions, roots, powers and subscripts, sums, products, integrals and limits, Greek
221
+ letters, relations and set notation, functions such as `\sin` and `\log`, absolute values and
222
+ norms, accents (`\vec`, `\hat`), `\binom`, factorials, primes, matrices, `cases`, and `\text{}` /
223
+ `\mathrm{}`. Units after a number work too: `5\,\mathrm{km}` is "five kilometers".
224
+
225
+ Good to know:
226
+
227
+ - **Parentheses** are only spoken where they change the meaning: `(a+b)^2` and `2(x+1)` get
228
+ "open paren ... close paren", `y = (x+1)` does not.
229
+ - **Complex arguments** are fenced so the listener can follow them: "the square root of x plus
230
+ one, end root".
231
+ - **Malformed LaTeX** never raises. It is left exactly as it was written.
232
+ - **Dollar signs:** a single `$` only starts math under the pandoc rule, so
233
+ `from $5 to $6` stays currency.
234
+ - **Turn it off** with `Normalizer("en", latex="keep")` to leave all math untouched.
235
+ - **Commands vs. functions:** `add_latex_command` is for symbol-like names (`\cdot`, `\R`),
236
+ `add_latex_function` for names applied to an argument (`\sinc x`). Commands win when both exist,
237
+ and `add_latex_function` warns if a command with that name already exists.
238
+
239
+ ## Streaming LLM output
240
+
241
+ When the LLM answers token by token, you do not want to wait for the full answer, and you cannot
242
+ normalize each token alone (`"1"`, `"20"`, `" km"` would be read separately). The stream wrapper
243
+ buffers text and releases it only when it is safe:
244
+
245
+ ```python
246
+ stream = n.stream()
247
+ for token in llm_tokens:
248
+ speakable = stream.feed(token) # "" until a safe boundary is reached
249
+ if speakable:
250
+ tts.say(speakable)
251
+ tts.say(stream.flush()) # call once at the end
252
+ ```
253
+
254
+ or, as a generator:
255
+
256
+ ```python
257
+ for speakable in n.normalize_stream(llm_tokens):
258
+ tts.say(speakable)
259
+ ```
260
+
261
+ What that looks like:
262
+
263
+ ```
264
+ feed 'The train ' -> ''
265
+ feed 'goes 1' -> ''
266
+ feed '20 km' -> ''
267
+ feed '/h. Dr' -> 'The train goes one hundred twenty kilometers per hour. '
268
+ feed '. Smith ' -> ''
269
+ feed 'pays $3' -> ''
270
+ feed '.50 now' -> ''
271
+ feed '. Done' -> ''
272
+ flush -> 'Doctor Smith pays three dollars and fifty cents now. Done'
273
+ ```
274
+
275
+ Text is released at the end of a sentence or at a newline. It is never cut inside a number and its
276
+ unit, an abbreviation (`Dr. Smith`), a date (`am 3. Mai`), a code block or a math span. The
277
+ combined output is identical to normalizing the whole text at once, whatever the chunk sizes (this
278
+ is tested). If a long run has no boundary, it is flushed after `max_buffer` characters (default
279
+ 400) at a whitespace that does not follow a number.
280
+
281
+ The trade-off: speech starts at the end of the first sentence, not at the first token.
282
+
283
+ ## Reference: options and API
284
+
285
+ ```python
286
+ Normalizer(lang="en", lexicon=None, latex="speak")
287
+ ```
288
+
289
+ | Argument | Meaning |
290
+ |---|---|
291
+ | `lang` | `"en"` or `"de"` (or a `Language` object) |
292
+ | `lexicon` | a `Lexicon` to use instead of the built-in data |
293
+ | `latex` | `"speak"` (default) reads math aloud, `"keep"` leaves it untouched |
294
+
295
+ | Call | What it does |
296
+ |---|---|
297
+ | `normalize(text, lang="en")` | one-shot helper; `lang` may be a code or your own `Normalizer` |
298
+ | `get_normalizer(lang)` | the shared instance behind `normalize(text, "en")` |
299
+ | `n.normalize(text)` (or `n(text)`) | normalize a string |
300
+ | `n.stream(max_buffer=400)` | a `StreamNormalizer` with `feed()` and `flush()` |
301
+ | `n.normalize_stream(chunks)` | generator over speakable pieces |
302
+ | `n.add_*`, `n.load_lexicon(path)` | extend the dictionaries (see above) |
303
+ | `n.lexicon` | the underlying `Lexicon` with the LaTeX methods |
304
+
305
+ ## How it works
306
+
307
+ Text is split into spans. Code, URLs, e-mails and HTML are kept as they are, math goes to the
308
+ LaTeX speaker, and everything else runs through these steps:
309
+
310
+ 1. **Abbreviations** (`Dr.` → "Doctor")
311
+ 2. **Dates** (`15.03.2024`, `March 3rd`)
312
+ 3. **Symbols that need digits** (`5 - 3`, `x^2`)
313
+ 4. **Numbers**, together with what follows them: units, percent, currency, ordinals, am/pm
314
+ 5. **Remaining symbols** (`=`, `+`, `²`, `≤`)
315
+
316
+ All regular expressions are compiled once and rebuilt only when the dictionaries change.
317
+
318
+ ```
319
+ src/normt2s/
320
+ __init__.py public API: normalize, get_normalizer
321
+ normalizer.py the pipeline above (start reading here)
322
+ lexicon.py the editable dictionaries and how they are loaded
323
+ stream.py buffering for streamed text
324
+ latex.py LaTeX tokenizer, parser and speaker
325
+ langs/ per-language rules: number words, times, dates, fractions
326
+ data/ en.json, de.json: abbreviations, units, currencies, symbols, math vocabulary
327
+ tests/ examples of every rule, a good way to see expected behaviour
328
+ benchmarks/ bench.py
329
+ ```
330
+
331
+ Rules that need code (how to build "twenty-one") live in `langs/`. Everything that is just a word
332
+ list lives in `data/`.
333
+
334
+ ## Adding a language
335
+
336
+ 1. Create `src/normt2s/langs/xx.py` with a subclass of `Language`: set the separators and words
337
+ (`thousands_sep`, `decimal_sep`, `minus`, `point`, `percent`, ...) and implement `cardinal`,
338
+ `ordinal`, `time` and `fraction`. Optionally add `year`, `decade`, `date_regex` and
339
+ `format_date`. End the file with `register(YourLanguage())`.
340
+ 2. Import it in `langs/__init__.py`.
341
+ 3. Create `src/normt2s/data/xx.json` in the format shown above.
342
+
343
+ `langs/en.py` and `langs/de.py` are working examples.
344
+
345
+ ## Performance
346
+
347
+ `python benchmarks/bench.py` on a laptop:
348
+
349
+ | Input | Time |
350
+ |---|---|
351
+ | a typical sentence (74 characters) | about 90 µs |
352
+ | a 600-character paragraph | about 0.9 ms |
353
+ | one-time startup (building and compiling) | about 30 ms |
354
+
355
+ ## Known limitations
356
+
357
+ - **Acronyms** such as "AI" or "LLM" are not expanded by default. Add the ones you need with
358
+ `add_abbreviation`.
359
+ - **Subtraction:** `-` is only read as "minus" between two numbers with spaces around it
360
+ (`5 - 3`). In `x - 1` it is left as it is. In LaTeX math it is always read.
361
+ - **German ordinal dots** are only recognised in dates (`am 3. Mai`), not in general
362
+ (`der 3. Platz`).
363
+ - **German number + noun:** `1 Stunde` reads "eins Stunde", because the gender of a spelled-out
364
+ noun is unknown. Use the unit symbol (`1 h`) to get "eine Stunde".
365
+ - **Dates:** English `3/15/2024` and `15 March 2024` are not handled, and neither are German
366
+ two-digit years.
367
+ - **Durations** such as `1:30:15` are not recognised.
368
+ - **Single capital-letter units** (`V`, `W`, `L`) only match when attached to the number (`12V`),
369
+ so that "5 A grade" is not read as five amperes.
370
+ - **LaTeX:** a lone `|` reads as "such that", so `P(A|B)` is not "given". Layout commands such as
371
+ `\overset` and `\underbrace` are not interpreted.
372
+
373
+ ## Development
374
+
375
+ ```
376
+ pip install -e .[dev]
377
+ pytest
378
+ python benchmarks/bench.py
379
+ ```
380
+
381
+ The tests double as documentation: `tests/test_en.py` and `tests/test_de.py` list the expected
382
+ output for each rule.
383
+
384
+ ## AI notice
385
+
386
+ This package was co-authored with Claude Code (Claude Sonnet 5.5, Anthropic). The code, tests and
387
+ documentation were written in collaboration between a human maintainer and the AI assistant.
388
+ Please review the output against your own requirements before relying on it in production.
389
+
390
+ ## License
391
+
392
+ MIT, see [LICENSE](LICENSE).