hangul-readout 0.0.0-stage → 0.1.3
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.
- package/LICENSE +21 -0
- package/README.en.md +470 -0
- package/README.md +470 -2
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +224 -0
- package/dist/data/abbreviations.d.ts +2 -0
- package/dist/data/abbreviations.js +31 -0
- package/dist/data/counters.d.ts +9 -0
- package/dist/data/counters.js +17 -0
- package/dist/data/exceptions.d.ts +12 -0
- package/dist/data/exceptions.js +24 -0
- package/dist/data/units.d.ts +2 -0
- package/dist/data/units.js +30 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +4 -0
- package/dist/normalize.d.ts +37 -0
- package/dist/normalize.js +182 -0
- package/dist/numerals.d.ts +20 -0
- package/dist/numerals.js +119 -0
- package/dist/rules.d.ts +54 -0
- package/dist/rules.js +395 -0
- package/dist/srt.d.ts +6 -0
- package/dist/srt.js +53 -0
- package/dist/ssml.d.ts +9 -0
- package/dist/ssml.js +27 -0
- package/dist/stream.d.ts +18 -0
- package/dist/stream.js +200 -0
- package/package.json +57 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LeeGyeongPil
|
|
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.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
# hangul-readout
|
|
2
|
+
|
|
3
|
+
[한국어](README.md) | **English**
|
|
4
|
+
|
|
5
|
+
A zero-dependency TypeScript library and CLI that rewrites Korean text the way a person reads it aloud, right before it goes to a TTS engine: `사과 3개` becomes `사과 세 개`, `3.5km` becomes `삼 점 오 킬로미터`, `010-1234-5678` becomes `공일공, 일이삼사, 오육칠팔`.
|
|
6
|
+
It reports which spans changed (offsets in the original text and in the output), can emit SSML `<sub alias>`, and ships a sentence chunker so streaming LLM tokens such as `3` / `.` / `5km` are never misread. Install: `npm install github:LeeGyeongPil/hangul-readout` (Node >= 22.12, ESM only).
|
|
7
|
+
|
|
8
|
+
## 1. Introduction
|
|
9
|
+
|
|
10
|
+
A library for people building Korean voice apps, voice agents or narration automation in JS/TS. Right **before** a string is handed to TTS, it turns numbers, units and dates into "spoken words". It has no runtime dependencies and runs offline, with no keys, network or GPU.
|
|
11
|
+
|
|
12
|
+
## 2. What it does
|
|
13
|
+
|
|
14
|
+
TTS engines read numbers and units differently from one another. One reads `3개` as "삼 개" (sam gae), another reads `100kg` as "백 케이지" (baek ke-i-ji). This library rewrites the text before it reaches the engine. It is like a proofreader penciling "read this as ..." onto a script for the narrator. The script (the subtitles) stays untouched; only the reading is written down separately.
|
|
15
|
+
|
|
16
|
+
| Original | Reading by this library | Why |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| 사과 3개 | 사과 세 개 | The counter `개` takes a native Korean numeral |
|
|
19
|
+
| 3년 | 삼 년 | The counter `년` takes a Sino-Korean numeral |
|
|
20
|
+
| 3.5km | 삼 점 오 킬로미터 | Decimal number with a unit |
|
|
21
|
+
| 2.5배 | 이 점 오 배 | Decimal number with a counter |
|
|
22
|
+
| -3℃ | 영하 삼 도 | Negative temperature |
|
|
23
|
+
| 010-1234-5678 | 공일공, 일이삼사, 오육칠팔 | Phone numbers are read digit by digit, with 0 as "공" |
|
|
24
|
+
| 2026-11-11 | 이천이십육 년 십일 월 십일 일 | Date |
|
|
25
|
+
| 3번 출구 / 3번 눌러 | 삼 번 출구 / 세 번 눌러 | `번` depends on whether it means a number or a count of times |
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
*Output of `node dist/cli.js --table examples/sample.txt`. It shows only the spots that changed in the sample file, as a table.*
|
|
30
|
+
|
|
31
|
+
## 3. Quick start
|
|
32
|
+
|
|
33
|
+
Install it. Node 22.12 or later is required (to install the prerequisites, for example: macOS `brew install node`, Debian/Ubuntu `sudo apt-get install -y nodejs npm git`).
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install github:LeeGyeongPil/hangul-readout
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Publishing to the npm registry is prepared but not done yet (once published: `npm install hangul-readout`). The `prepare` script builds `dist/` during installation, so a git install can be imported right away. On npm 11 and later you may see a warning that the `prepare` script is not yet in `allowScripts`. This is not an install failure; the build still runs and the package can be imported immediately.
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { normalize } from "hangul-readout";
|
|
43
|
+
|
|
44
|
+
const { text, spans } = normalize("사과 3개를 사서 3.5km를 걸었어요. 문의는 010-1234-5678");
|
|
45
|
+
console.log(text);
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
사과 세 개를 사서 삼 점 오 킬로미터를 걸었어요. 문의는 공일공, 일이삼사, 오육칠팔
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Pass `text` to TTS, and keep using the original string for the subtitles shown on screen. You can also use it from the command line inside a project where it is installed.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
echo "사과 3개" | npx hangul-readout
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
사과 세 개
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
To clone the repository and try it yourself, run the following (from install to the table output takes a few minutes).
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git clone https://github.com/LeeGyeongPil/hangul-readout.git
|
|
66
|
+
cd hangul-readout
|
|
67
|
+
npm ci
|
|
68
|
+
npm start
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`npm start` builds the project and prints the change table for `examples/sample.txt`. The screenshot above is that result.
|
|
72
|
+
|
|
73
|
+
## 4. Usage
|
|
74
|
+
|
|
75
|
+
### 4.1 `normalize` and spans
|
|
76
|
+
|
|
77
|
+
`normalize(text, opts?)` returns `{ text, spans }`. `spans` is a list of "what changed where in the original".
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
import { normalize } from "hangul-readout";
|
|
81
|
+
|
|
82
|
+
const r = normalize("사과 3개와 배 20개");
|
|
83
|
+
console.log(r.text);
|
|
84
|
+
console.log(JSON.stringify(r.spans[0]));
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
사과 세 개와 배 스무 개
|
|
89
|
+
{"start":3,"end":5,"outStart":3,"outEnd":6,"source":"3개","reading":"세 개","rule":"native-counter"}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- `start`/`end`: position in the original text (UTF-16 offsets, the same unit as `String.prototype.slice`).
|
|
93
|
+
- `outStart`/`outEnd`: position in the resulting `text`.
|
|
94
|
+
- `source`/`reading`: the text before the change and its reading. `rule`: which rule made the choice.
|
|
95
|
+
|
|
96
|
+
It never throws for any string (empty string, emoji, very long numbers). Passing a non-string throws a `HangulReadoutError` (`normalize(null)` → `normalize(): expected a string, got null.`).
|
|
97
|
+
|
|
98
|
+
### 4.2 `toSSML`
|
|
99
|
+
|
|
100
|
+
For cloud TTS services that accept SSML (XML for speech synthesis). It wraps each changed spot in `<sub alias="reading">original</sub>`. `& < > " '` are escaped.
|
|
101
|
+
|
|
102
|
+
```js
|
|
103
|
+
import { toSSML } from "hangul-readout";
|
|
104
|
+
console.log(toSSML("사과 3개"));
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
<speak>사과 <sub alias="세 개">3개</sub></speak>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+

|
|
112
|
+
|
|
113
|
+
*The original is kept and only the sound changes. `&` becomes `&` as XML requires.*
|
|
114
|
+
|
|
115
|
+
### 4.3 `sentences` and `normalizeStream` (LLM streams)
|
|
116
|
+
|
|
117
|
+
An LLM sends its answer as token fragments. A split can land in the middle of a number, such as `3` / `.` / `5km`. `sentences` collects fragments and emits them **only after a sentence is confirmed**, and `normalizeStream` adds `normalize` on top of that. The input is an `AsyncIterable<string>` or an `Iterable<string>`.
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
import { sentences, normalizeStream } from "hangul-readout";
|
|
121
|
+
|
|
122
|
+
const tokens = ["거리는 3", ".", "5km 입니다", ". 다음은 ", "사과 3", "개예요. 끝"];
|
|
123
|
+
|
|
124
|
+
for await (const s of sentences(tokens)) console.log(s);
|
|
125
|
+
for await (const s of normalizeStream(tokens)) console.log(s.text);
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
```text
|
|
129
|
+
거리는 3.5km 입니다.
|
|
130
|
+
다음은 사과 3개예요.
|
|
131
|
+
끝
|
|
132
|
+
거리는 삼 점 오 킬로미터 입니다.
|
|
133
|
+
다음은 사과 세 개예요.
|
|
134
|
+
끝
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`normalizeStream` yields `{ sentence, text, spans }`. It uses no time-based flush (timeout), so the same input always produces the same result. How it works is explained in section 8.
|
|
138
|
+
|
|
139
|
+
### 4.4 `createNormalizer` and options
|
|
140
|
+
|
|
141
|
+
Build the options and rules once and reuse them many times. Each call uses only local state, so running several streams at the same time does not mix them up.
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
import { createNormalizer } from "hangul-readout";
|
|
145
|
+
|
|
146
|
+
const n = createNormalizer({
|
|
147
|
+
counters: { native: ["포기"] },
|
|
148
|
+
abbreviations: { RAG: "래그" },
|
|
149
|
+
units: { "㎡": "제곱미터" },
|
|
150
|
+
});
|
|
151
|
+
console.log(n.normalize("배추 3포기, RAG 30㎡").text);
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```text
|
|
155
|
+
배추 세 포기, 래그 삼십 제곱미터
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
| Option | Default | Meaning |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| `over99` | `"mixed"` | How native-Korean counters read numbers of 100 and above (section 5) |
|
|
161
|
+
| `counters` | built-in table | `{ native?: string[]; sino?: string[] }` adds counters |
|
|
162
|
+
| `units` | built-in table | `{ "㎡": "제곱미터" }` unit symbol → reading |
|
|
163
|
+
| `abbreviations` | built-in table | `{ RAG: "래그" }` English abbreviation → reading |
|
|
164
|
+
| `beonNumberHints` | built-in list | Words that make a preceding `번` read as a number (Sino-Korean) |
|
|
165
|
+
| `disable` | none | Array of rule names to turn off |
|
|
166
|
+
|
|
167
|
+
The chunk options `minChars` (default 8) and `maxChars` (default 160) are used by `sentences` and `normalizeStream`. Long text with no whitespace at all is cut by force at `max(maxChars*8, 2048)` characters.
|
|
168
|
+
|
|
169
|
+
### 4.5 CLI
|
|
170
|
+
|
|
171
|
+
```text
|
|
172
|
+
hangul-readout [options] [file | -]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
With no file, or with `-`, it reads standard input.
|
|
176
|
+
|
|
177
|
+
| Option | What it does |
|
|
178
|
+
|---|---|
|
|
179
|
+
| (none) | Prints the text rewritten as readings |
|
|
180
|
+
| `--json` | Prints `{ text, spans }` as JSON |
|
|
181
|
+
| `--table` | Prints a table of the changed spots (original, reading, rule, position) |
|
|
182
|
+
| `--ssml` | Prints `<sub alias>` SSML |
|
|
183
|
+
| `--srt` | Treats the input as SRT: keeps the index and timing lines and rewrites only the dialogue |
|
|
184
|
+
| `--over99 <m>` | `mixed` (default) or `sino` |
|
|
185
|
+
| `--debug` | Prints a stack trace on error |
|
|
186
|
+
| `--help`, `--version` | Help, version |
|
|
187
|
+
|
|
188
|
+
| Exit code | Meaning |
|
|
189
|
+
|---|---|
|
|
190
|
+
| 0 | Success (empty input is also success) |
|
|
191
|
+
| 2 | Every error: unknown option, missing file, invalid `--over99` value, malformed SRT, and so on |
|
|
192
|
+
|
|
193
|
+
Errors appear only as a single line on stderr (`hangul-readout: error: …`); a stack trace appears only with `--debug`.
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
node dist/cli.js --nope 2>&1; echo "exit code: $?"
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```text
|
|
200
|
+
hangul-readout: error: unknown option --nope
|
|
201
|
+
exit code: 2
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+

|
|
205
|
+
|
|
206
|
+
*An error case. Only one line is printed, with no traceback, and the exit code is 2.*
|
|
207
|
+
|
|
208
|
+
For SRT subtitle files, only the dialogue is rewritten as readings.
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
node dist/cli.js --srt examples/sample.srt
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```text
|
|
215
|
+
1
|
|
216
|
+
00:00:01,000 --> 00:00:03,500
|
|
217
|
+
오늘은 사과 세 개와 배 스무 개를 샀어요.
|
|
218
|
+
|
|
219
|
+
2
|
|
220
|
+
00:00:03,500 --> 00:00:06,000
|
|
221
|
+
집에서 마트까지 삼 점 오 킬로미터 걸었고
|
|
222
|
+
기온은 영하 삼 도 였어요.
|
|
223
|
+
|
|
224
|
+
3
|
|
225
|
+
00:00:06,000 --> 00:00:09,000
|
|
226
|
+
문의는 공일공, 일이삼사, 오육칠팔 로 주세요.
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+

|
|
230
|
+
|
|
231
|
+
*To save the result to a file: `npx hangul-readout --srt narration.srt > narration.read.srt`.*
|
|
232
|
+
|
|
233
|
+
## 5. Reading rules
|
|
234
|
+
|
|
235
|
+
The scanner sweeps the text from left to right, and when it meets a number it tries the rules below **from the top**. The first matching rule decides the reading, and if a rule declines ("this is not mine") the next rule is tried. So the order is how ambiguity gets resolved (phone numbers come before plain numbers).
|
|
236
|
+
|
|
237
|
+
| Order | Rule | Example → reading |
|
|
238
|
+
|---|---|---|
|
|
239
|
+
| 1 | `phone` | 010-1234-5678 → 공일공, 일이삼사, 오육칠팔 (also main numbers like 1588-1234, `(02) 123-4567`, `+82-10-…`, and the emergency number 119) |
|
|
240
|
+
| 2 | `date` | 2026-11-11 → 이천이십육 년 십일 월 십일 일, 11/11 → 십일 월 십일 일 |
|
|
241
|
+
| 3 | `protect` | 192.168.0.1, 1.2.3, `https://…` → left exactly as they are |
|
|
242
|
+
| 4 | `clock` | 3:30 → 세 시 삼십 분 |
|
|
243
|
+
| 5 | `ratio` | 16:9 → 십육 대 구, 1:1 → 일 대 일 |
|
|
244
|
+
| 6 | `fraction` | 1/3 → 삼분의 일 |
|
|
245
|
+
| 7 | `range` | 2~3명 → 두세 명, 5~6시 → 다섯 시에서 여섯 시, 30~40% → 삼십에서 사십 퍼센트 |
|
|
246
|
+
| 8 | `unit` | 3.5km → 삼 점 오 킬로미터, -3℃ → 영하 삼 도, 30°C → 삼십 도 |
|
|
247
|
+
| 9 | `currency` | $5 → 오 달러, ₩5000 → 오천 원, ₩5,000원 → 오천 원 |
|
|
248
|
+
| 10 | `month` | 6월 → 유월, 11월 → 십일 월 |
|
|
249
|
+
| 11 | `hour` | 10시 → 열 시 (`시간` is excluded) |
|
|
250
|
+
| 12 | `ordinal` | 1번째 → 첫 번째, 2번째 → 두 번째 |
|
|
251
|
+
| 13 | `beon` | 3번 출구 → 삼 번 출구 / 3번 눌러 → 세 번 눌러 |
|
|
252
|
+
| 14 | `native-counter` | 3개 → 세 개 |
|
|
253
|
+
| 15 | `sino-counter` | 3년 → 삼 년, 20세 → 이십 세, 제1장 → 제일 장, 30대 → 삼십 대. Decimals are read in Sino-Korean even before native counters: 1.5배 → 일 점 오 배, 2.5시간 → 이 점 오 시간 |
|
|
254
|
+
| 16 | `digits` | Digit strings of 17 or more digits, or starting with 0 → one digit at a time |
|
|
255
|
+
| 17 | `number` | 12,345 → 만 이천삼백사십오 |
|
|
256
|
+
| - | `abbreviation` | AI → 에이아이, 5G → 파이브지 |
|
|
257
|
+
|
|
258
|
+
**Whether a number is native or Sino-Korean is decided by the counter that follows, not by the number.** `3개` is "세 개" and `3년` is "삼 년". That is why a function that merely converts numbers cannot process a sentence; the counter after the number has to be looked at too.
|
|
259
|
+
|
|
260
|
+
**The over-99 option.** There is no single correct way to read 100 and above with native Korean numerals, so it is chosen by an option.
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
import { normalize } from "hangul-readout";
|
|
264
|
+
console.log(normalize("120명").text, normalize("120명", { over99: "sino" }).text);
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```text
|
|
268
|
+
백스무 명 백이십 명
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
A few exceptions are built into the rules as well.
|
|
272
|
+
|
|
273
|
+
```js
|
|
274
|
+
console.log(normalize("6월 10월 11월").text, normalize("3시간 3시").text);
|
|
275
|
+
console.log(normalize("A4 용지 3장 COVID-19").text);
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
```text
|
|
279
|
+
유월 시월 십일 월 세 시간 세 시
|
|
280
|
+
A4 용지 세 장 COVID-19
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
- `6월` and `10월` are read "유월" and "시월".
|
|
284
|
+
- The `3` in `3시간` is "세"; since `시간` is the unit, the counter rule handles it rather than the `hour` rule.
|
|
285
|
+
- Numbers attached to English letters (`A4`, `MP3`, `COVID-19`, `v2.0`) are left alone. For `5G`, `3D` and `4K`, where the number comes first, the whole token is read if it is in the abbreviation table (`5G` → 파이브지) and left whole as it is if not (`1st`, `1e5`). Half of a token is never converted.
|
|
286
|
+
- `제1장` → 제일 장, `20대` → 이십 대, `2대` (vehicles) → 두 대. `제` followed by a number is Sino-Korean, and a multiple of 10 followed by `대` is read as an age group.
|
|
287
|
+
- A range is read only when both sides share the same counter or unit (`5~6시` → 다섯 시에서 여섯 시); if it cannot be read, it is left whole as it is, like `1~2.5명`.
|
|
288
|
+
|
|
289
|
+
```js
|
|
290
|
+
console.log(normalize("5~6시 30~40% 1~2만 원").text);
|
|
291
|
+
console.log(normalize("30°C 5G $5 11/11 16:9").text);
|
|
292
|
+
console.log(normalize("1588-1234 (02) 123-4567 +82-10-1234-5678").text);
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
```text
|
|
296
|
+
다섯 시에서 여섯 시 삼십에서 사십 퍼센트 일에서 이만 원
|
|
297
|
+
삼십 도 파이브지 오 달러 십일 월 십일 일 십육 대 구
|
|
298
|
+
일오팔팔, 일이삼사 (공이), 일이삼, 사오육칠 플러스 팔이, 일공, 일이삼사, 오육칠팔
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
`번` is decided by context. If a number-hint word follows it right away (출구, 버스, 선, 게이트, 트랙, 승강장, 홈, 문제, 채널, 방, 국도, 타자, 라인), or if it is preceded by `호선`, `번호는` or `정답은`, it is read as a number (Sino-Korean); otherwise it is read as a count of times (native Korean).
|
|
302
|
+
|
|
303
|
+

|
|
304
|
+
|
|
305
|
+
*`3번 출구` becomes "삼 번", `3번 돌아요` becomes "세 번", and `1번째` becomes "첫 번째".*
|
|
306
|
+
|
|
307
|
+
## 6. Adding your own rules
|
|
308
|
+
|
|
309
|
+
Options passed to `normalize` and `createNormalizer` are added on top of the built-in tables.
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
import { normalize } from "hangul-readout";
|
|
313
|
+
|
|
314
|
+
console.log(normalize("3번 주차장").text);
|
|
315
|
+
console.log(normalize("3번 주차장", { beonNumberHints: ["주차장"] }).text);
|
|
316
|
+
console.log(normalize("AI 모델", { disable: ["abbreviation"] }).text);
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
```text
|
|
320
|
+
세 번 주차장
|
|
321
|
+
삼 번 주차장
|
|
322
|
+
AI 모델
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
- `counters`: adds counters as native or Sino-Korean (`{ native: ["포기"] }`).
|
|
326
|
+
- `units`: adds unit symbols (`{ "㎡": "제곱미터" }`).
|
|
327
|
+
- `abbreviations`: adds abbreviations (`{ RAG: "래그" }`). Matching is case-sensitive.
|
|
328
|
+
- `beonNumberHints`: adds words that make `번` read as a number.
|
|
329
|
+
- `disable`: turns off a rule when given its name (`RuleName`). The names are `phone`, `date`, `protect`, `clock`, `ratio`, `fraction`, `range`, `unit`, `currency`, `month`, `hour`, `ordinal`, `beon`, `native-counter`, `sino-counter`, `digits`, `number` and `abbreviation`.
|
|
330
|
+
- Keys of `abbreviations` may start with a digit, as in `{ "6G": "식스지" }`.
|
|
331
|
+
|
|
332
|
+
If you find an ambiguous sentence, you can also pin it by adding one more line to `test/golden.json` (section 11).
|
|
333
|
+
|
|
334
|
+
## 7. Aligning original and reading (spans) and subtitles
|
|
335
|
+
|
|
336
|
+
When you want to use the reading for audio and the original for the screen, spans connect the two strings. A span is a correspondence table: "this stretch of the original is that stretch of the reading", and the `<sub alias>` in `toSSML` expresses the same idea in XML.
|
|
337
|
+
|
|
338
|
+
Offsets are in UTF-16 code units. An emoji takes two units, yet `input.slice(start, end)` still gives exactly `source`.
|
|
339
|
+
|
|
340
|
+
```js
|
|
341
|
+
import { normalize } from "hangul-readout";
|
|
342
|
+
|
|
343
|
+
const input = "👍 사과 3개";
|
|
344
|
+
const e = normalize(input);
|
|
345
|
+
const s = e.spans[0];
|
|
346
|
+
console.log(JSON.stringify(s));
|
|
347
|
+
console.log(input.slice(s.start, s.end), "->", e.text.slice(s.outStart, s.outEnd));
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
```text
|
|
351
|
+
{"start":6,"end":8,"outStart":6,"outEnd":9,"source":"3개","reading":"세 개","rule":"native-counter"}
|
|
352
|
+
3개 -> 세 개
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Why `start` is 6: the emoji 👍 takes 2 units (two UTF-16 code units), then one space, `사과` takes 2, and one more space, so `3개` starts at position 6. Tests pin down that `slice` stays correct even with emoji, ZWJ sequences and unpaired surrogates mixed in.
|
|
356
|
+
|
|
357
|
+
Subtitle highlighting example: wrapping only the `[start, end)` stretch of the original in `<mark>` lets you show "where the reading changed" in the subtitles, and `outStart`/`outEnd` let you find the same spot in the string on the audio-timing side.
|
|
358
|
+
|
|
359
|
+
## 8. Streaming: how chunk boundaries are respected
|
|
360
|
+
|
|
361
|
+
> **This is a practice fake LLM stream.** `examples/fake-llm-stream.mjs` is not a real model; it is a practice (mock) example that emits fixed token fragments split in the middle of numbers. The sentence splitting and reading conversion are the real library code, and the output is what actually came out of the library. No real LLM or TTS calls are included in this repository's examples or tests.
|
|
362
|
+
|
|
363
|
+
```bash
|
|
364
|
+
node examples/fake-llm-stream.mjs
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
```text
|
|
368
|
+
[practice mock] fake token stream -> real sentence chunker + normalizer
|
|
369
|
+
token "연락처는 010-"
|
|
370
|
+
token "1234-5678 입니다. "
|
|
371
|
+
token "사과 3"
|
|
372
|
+
sentence 연락처는 010-1234-5678 입니다.
|
|
373
|
+
reading 연락처는 공일공, 일이삼사, 오육칠팔 입니다.
|
|
374
|
+
token "개예요. "
|
|
375
|
+
token "거리는 3"
|
|
376
|
+
sentence 사과 3개예요.
|
|
377
|
+
reading 사과 세 개예요.
|
|
378
|
+
token "."
|
|
379
|
+
token "5km 입니다."
|
|
380
|
+
sentence 거리는 3.5km 입니다.
|
|
381
|
+
reading 거리는 삼 점 오 킬로미터 입니다.
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+

|
|
385
|
+
|
|
386
|
+
*Even after `입니다. ` arrives, the sentence is not emitted immediately; the earlier sentence comes out only after the next token `사과 3` arrives.*
|
|
387
|
+
|
|
388
|
+
```mermaid
|
|
389
|
+
flowchart LR
|
|
390
|
+
T["Token fragment"] --> B["Collect in buffer"]
|
|
391
|
+
B --> C{"Sentence-end candidate?"}
|
|
392
|
+
C -- No --> B
|
|
393
|
+
C -- "At end of buffer" --> W["Wait until the next character is seen"]
|
|
394
|
+
W --> B
|
|
395
|
+
C -- Confirmed --> S["Emit sentence"]
|
|
396
|
+
S --> N[normalize]
|
|
397
|
+
N --> O["To TTS"]
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
- **lookahead**: if a sentence-end candidate (`.`, `?`, `!`, etc.) is at the very end of the buffer, confirmation is deferred until the next character is seen. If a `.` follows `3` and then a `5` comes after it, you can tell it was a decimal point.
|
|
401
|
+
- **Decimal points and list numbers**: a period between digits (`3.5`) and a list number (`1. `) are not treated as sentence ends. Ellipses are not treated as boundaries either. A line break is always a sentence end.
|
|
402
|
+
- **`maxChars`**: if one sentence is too long, it is split by force at a comma or whitespace, but never inside a number or symbol token such as `010-1234-5678`, `3.5km` or `2~3명`.
|
|
403
|
+
|
|
404
|
+
```js
|
|
405
|
+
import { sentences } from "hangul-readout";
|
|
406
|
+
|
|
407
|
+
const text = "연락처는 010-1234-5678 이고, 거리는 3.5km, 인원은 2~3명입니다.";
|
|
408
|
+
const out = [];
|
|
409
|
+
for await (const s of sentences([text], { maxChars: 12 })) out.push(s);
|
|
410
|
+
console.log(JSON.stringify(out));
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
```text
|
|
414
|
+
["연락처는","010-1234-5678","이고, 거리는","3.5km, 인원은","2~3명입니다."]
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
However the tokens are split on the way in (even one character at a time), the same sentences come out. This property is pinned by tests. Normalizing only after a sentence is confirmed is the key to preventing misreadings at chunk boundaries.
|
|
418
|
+
|
|
419
|
+
## 9. Limits and ambiguous readings
|
|
420
|
+
|
|
421
|
+
Some readings have no single correct answer. Defaults are chosen, and they can be changed with options and the golden table.
|
|
422
|
+
|
|
423
|
+
- **`번`**: the default is a count of times (native Korean). The words that make it read as a number grow only through the hint list and `beonNumberHints`. `3번 방법` is read as a count of times.
|
|
424
|
+
- **No particle agreement**: even when the reading changes, the following particle is not changed. `3개를` → `세 개를` is right, but where the final consonant changes (`1명이` → `한 명이`, etc.) the particle is not adjusted to fit the reading.
|
|
425
|
+
- **Range counters**: `2~3명` → `두세 명` and `5~6시` → `다섯 시에서 여섯 시` work, but `2~3일` is read as `이에서 삼 일` (not the natural `이삼 일`). Ranges that cannot be read (`1~2.5명`, `01~02월`) are left whole as they are, and `2-3` with no hyphen tail is not treated as a range.
|
|
426
|
+
- **`대`**: when the actual count is exactly a multiple of 10 (10 to 90), as in `20대의 서버`, it is read as an age group. `2대`, `5대` and `100대` are vehicles (native Korean).
|
|
427
|
+
- **`NN/NN` that looks like a date**: it is a date only when both sides have two digits, month ≤ 12 and day ≤ 31. `1/2` and `3/4` are fractions.
|
|
428
|
+
- **Currency**: the symbol, the number and a directly attached name of the same currency are read together (`$5`, `₩5000`, `₩5,000원`, and `$5달러` reads the name only once). With a space, as in `₩5,000 원`, only the symbol and number are read and the following name is read separately, so the name comes out twice (`오천 원 원`). A currency name that differs from the symbol (`$5원`) is not absorbed (`오 달러원`).
|
|
429
|
+
- **Markdown symbols are not removed.** As in `**3**개` → `**세** 개`, a `*`, `_` or `` ` `` between a number and its counter is skipped when reading, but the symbol stays in the output. TTS may read `**` as "asterisk", so strip the symbols on the calling side.
|
|
430
|
+
- **Very long words**: text with no whitespace that exceeds `max(maxChars*8, 2048)` characters (long URLs, base64, etc.) is cut in the middle.
|
|
431
|
+
- **개월, 석 달, 인분**: old forms such as 석 달 and 넉 장 are not supported.
|
|
432
|
+
- **Units and abbreviations not in the dictionary**: the built-in tables are a small set of defaults. Words not in the table (such as `GPT-4`) are left as they are; add them with `units` and `abbreviations`.
|
|
433
|
+
- **Whitespace and line breaks**: a single space before a unit is allowed (`5 km`), but two or more spaces or a line break are not joined.
|
|
434
|
+
- **Date notation and versions**: versions and IPs such as `1.2.3` and `192.168.0.1`, and URLs, are left whole as they are. A date with spaces in it, like `2026. 11. 11.`, may be cut at `2026.` in a stream.
|
|
435
|
+
- Some ambiguous readings (`3대 1` → `세 대 일`) are a limit of rule-based processing.
|
|
436
|
+
- It does no pronunciation conversion (liaison, tensification), no Hanja readings and no morphological analysis.
|
|
437
|
+
- The examples, tests (279) and typecheck passed on Node 22.12.0 and 26.13.0, and the CI matrix (22, 24, 26) keeps checking them.
|
|
438
|
+
- **Known limits (awaiting a code fix)**: some combinations of the `~` and `-` range symbols are not read as ranges or are read awkwardly. Ordinal reading may be over-applied to a number written with a space after "제", as in `제 3개`. The reading of some counters such as `4강`, and of a standalone `+82` country code, may sound unnatural.
|
|
439
|
+
|
|
440
|
+
## 10. Comparison with existing alternatives
|
|
441
|
+
|
|
442
|
+
No feature table is made. Other tools' features change, so below only the date checked and the purpose are given. (All checked on 2026-11-11.)
|
|
443
|
+
|
|
444
|
+
- If you need a **function that converts a single number to Hangul**, [es-hangul](https://github.com/toss/es-hangul) (2.4.0) or [korean-number-count](https://socket.dev/npm/package/korean-number-count) (1.0.0) fit that purpose. This library handles what sits on top of them: "find the numbers inside a sentence and choose the reading from the counter that follows". For the numerals 1 to 99, tests compare that it gives the same values as es-hangul.
|
|
445
|
+
- For a **Python-side speech pipeline**, [NeMo-text-processing](https://github.com/NVIDIA/NeMo-text-processing) has Korean rules. It is based on Python and pynini, so it is hard to use in browsers, Node or at the edge.
|
|
446
|
+
- If you need **general multilingual normalization**, you can look at @revolab/revonorm (an npm package). The version checked, 1.1.4, raised an error on Korean (`ko`) calls.
|
|
447
|
+
- The **sentence splitters of speech frameworks** ([Pipecat](https://reference-server.pipecat.ai/en/latest/_modules/pipecat/utils/text/simple_text_aggregator.html), [LiveKit Agents](https://docs.livekit.io/reference/python/livekit/agents/tokenize/basic.html)) are general-purpose across languages and are separate from reading Korean numbers.
|
|
448
|
+
|
|
449
|
+
## 11. Development
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
npm ci && npm run typecheck && npm test
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
`npm test` builds in `pretest` and then runs `vitest run`. All tests run offline and deterministically, and need no keys. The result confirmed in this repository is `Test Files 10 passed`, `Tests 279 passed`.
|
|
456
|
+
|
|
457
|
+
**Adding a golden row**: `test/golden.json` is an array of `{ "input", "expected", "options"?, "note"? }` (currently 147 rows). When you find a sentence that is read wrongly, add one line and check with `npx vitest run test/golden.test.ts`. Each row's test name is its input text.
|
|
458
|
+
|
|
459
|
+
```json
|
|
460
|
+
{ "input": "사과 3개", "expected": "사과 세 개", "note": "native counter takes the attributive form" }
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
- Supported Node: `>=22.12`, ESM only. CI runs `npm ci` → `npm run typecheck` → `npm test` on Node 22, 24 and 26.
|
|
464
|
+
- The build is done only with `tsc -p tsconfig.build.json` (`npm run build`).
|
|
465
|
+
- Versions follow semantic versioning (SemVer), and during 0.x the API may change in minor versions. Changes are recorded in [CHANGELOG.md](CHANGELOG.md).
|
|
466
|
+
- For reporting security issues, see [SECURITY.md](SECURITY.md).
|
|
467
|
+
|
|
468
|
+
## 12. License
|
|
469
|
+
|
|
470
|
+
MIT. See [LICENSE](LICENSE). The tables of counters, units and exceptions, and the example sentences, were written directly with reference to publicly available descriptions of the rules.
|