termux-tts 0.1.1

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.
Files changed (43) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +31 -0
  3. package/README.pypi.md +47 -0
  4. package/bin/cli.js +33 -0
  5. package/binding_node/index.js +117 -0
  6. package/binding_node/test_node.js +40 -0
  7. package/doc.config.yaml +98 -0
  8. package/docs/benchmarks.md +12 -0
  9. package/docs/guide.md +552 -0
  10. package/docs/tts_guide.md +552 -0
  11. package/dsp_test.wav +0 -0
  12. package/expressive_demo.wav +0 -0
  13. package/g2p_test.wav +0 -0
  14. package/index.js +5 -0
  15. package/install.sh +53 -0
  16. package/package.json +31 -0
  17. package/pyproject.toml +43 -0
  18. package/setup.py +38 -0
  19. package/termux_tts/__init__.py +47 -0
  20. package/termux_tts/adapter.py +99 -0
  21. package/termux_tts/audio.py +75 -0
  22. package/termux_tts/cli.py +82 -0
  23. package/termux_tts/control/__init__.py +4 -0
  24. package/termux_tts/control/component.py +242 -0
  25. package/termux_tts/control/errors.py +52 -0
  26. package/termux_tts/control/instances.py +137 -0
  27. package/termux_tts/control/models.py +151 -0
  28. package/termux_tts/control/status.py +13 -0
  29. package/termux_tts/engine.py +157 -0
  30. package/termux_tts/engine_dsp.py +332 -0
  31. package/termux_tts/engine_native.py +104 -0
  32. package/termux_tts/engine_onnx.py +199 -0
  33. package/termux_tts/exceptions.py +28 -0
  34. package/termux_tts/g2p_korean.py +242 -0
  35. package/termux_tts/tokenizer.py +184 -0
  36. package/termux_tts/vulkan_probe.py +25 -0
  37. package/test_cli.wav +0 -0
  38. package/tests/test_expressive_presets.py +29 -0
  39. package/tests/test_g2p_korean.py +73 -0
  40. package/tests/test_granular_tts.py +141 -0
  41. package/tests/test_native_engine.py +38 -0
  42. package/tests/test_onnx_engine.py +48 -0
  43. package/tests/test_vulkan_routing.py +52 -0
@@ -0,0 +1,242 @@
1
+ """
2
+ Production-Grade Korean Grapheme-to-Phoneme (G2P) Engine for termux-tts.
3
+ Strict adherence to National Institute of Korean Language (국립국어원) Standard Pronunciation Rules.
4
+ Implements:
5
+ 1. Neutralization (평폐쇄음화 및 겹받침 단순화, 제8/9항)
6
+ 2. Liaison (연음 및 ㅎ 탈락, 제13/14/12항)
7
+ 3. Palatalization (구개음화: ㄷ,ㅌ+ㅣ -> ㅈ,ㅊ, 제17항)
8
+ 4. Aspiration (격음화/거센소리되기: ㄱ,ㄷ,ㅂ,ㅈ+ㅎ -> ㅋ,ㅌ,ㅍ,ㅊ, 제12항)
9
+ 5. Nasalization (비음화: ㄱ,ㄷ,ㅂ+ㄴ,ㅁ -> ㅇ,ㄴ,ㅁ 및 ㅁ,ㅇ+ㄹ -> ㄴ, 제18/19항)
10
+ 6. Liquidization (유음화: ㄴ+ㄹ, ㄹ+ㄴ -> ㄹ+ㄹ, 제20항)
11
+ 7. Tensification (경음화/된소리되기: ㄱ,ㄷ,ㅂ+ㄱ,ㄷ,ㅂ,ㅅ,ㅈ -> ㄲ,ㄸ,ㅃ,ㅆ,ㅉ, 제23항)
12
+ 8. Sino-Korean & Compound Exceptions (한자어/사이시옷 특수 발음 사전)
13
+ """
14
+
15
+ from typing import Tuple, Dict
16
+
17
+ HANGUL_BASE = 0xAC00
18
+ HANGUL_END = 0xD7A3
19
+
20
+ CHO_LIST = [
21
+ 'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
22
+ 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
23
+ ]
24
+ JUNG_LIST = [
25
+ 'ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ', 'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ',
26
+ 'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ', 'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ', 'ㅣ'
27
+ ]
28
+ JONG_LIST = [
29
+ '', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ', 'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ',
30
+ 'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ', 'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ',
31
+ 'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
32
+ ]
33
+
34
+ CHO_MAP = {c: i for i, c in enumerate(CHO_LIST)}
35
+ JUNG_MAP = {c: i for i, c in enumerate(JUNG_LIST)}
36
+ JONG_MAP = {c: i for i, c in enumerate(JONG_LIST)}
37
+
38
+ # 대표 종성 중화 맵 (제8항)
39
+ NEUTRAL_JONG = {
40
+ 'ㄲ': 'ㄱ', 'ㄳ': 'ㄱ', 'ㅋ': 'ㄱ', 'ㄺ': 'ㄱ',
41
+ 'ㅅ': 'ㄷ', 'ㅆ': 'ㄷ', 'ㅈ': 'ㄷ', 'ㅊ': 'ㄷ', 'ㅌ': 'ㄷ', 'ㅎ': 'ㄷ', 'ㄵ': 'ㄴ', 'ㄶ': 'ㄴ',
42
+ 'ㄼ': 'ㄹ', 'ㄽ': 'ㄹ', 'ㄾ': 'ㄹ', 'ㅀ': 'ㄹ',
43
+ 'ㄻ': 'ㅁ',
44
+ 'ㄿ': 'ㅂ', 'ㅄ': 'ㅂ', 'ㅍ': 'ㅂ'
45
+ }
46
+
47
+ # 겹받침 연음 시 분리 맵 (제14항: 닭을 -> 달글, 값을 -> 갑슬)
48
+ DOUBLE_JONG_SPLIT = {
49
+ 'ㄳ': ('ㄱ', 'ㅅ'),
50
+ 'ㄵ': ('ㄴ', 'ㅈ'),
51
+ 'ㄶ': ('ㄴ', 'ㅎ'),
52
+ 'ㄺ': ('ㄹ', 'ㄱ'),
53
+ 'ㄻ': ('ㄹ', 'ㅁ'),
54
+ 'ㄼ': ('ㄹ', 'ㅂ'),
55
+ 'ㄽ': ('ㄹ', 'ㅅ'),
56
+ 'ㄾ': ('ㄹ', 'ㅌ'),
57
+ 'ㄿ': ('ㄹ', 'ㅍ'),
58
+ 'ㅀ': ('ㄹ', 'ㅎ'),
59
+ 'ㅄ': ('ㅂ', 'ㅅ')
60
+ }
61
+
62
+ # 한자어/특수 복합어 불규칙 예외 사전
63
+ IRREGULAR_WORDS = {
64
+ '신라': '실라', '난로': '날로', '칼날': '칼랄', '물약': '물략',
65
+ '생산량': '생산냥', '결단력': '결딴녁', '의견란': '의견난', '임진란': '임진난',
66
+ '이원론': '이원논', '입원료': '이붠뇨', '동양루': '동양누', '구원투수': '구원투수',
67
+ '금융': '금늉', '식용유': '식용뉴', '맨입': '맨닙', '솜이불': '솜니불',
68
+ '눈요기': '눈뇨기', '남존여비': '남존녀비', '신여성': '신녀성', '꽃잎': '꼰닙',
69
+ '깻잎': '깬닙', '나뭇잎': '나문닙', '학여울': '항녀울', '독립': '동닙',
70
+ '백로': '뱅노', '협력': '혐녁', '국립': '궁닙', '막일': '망닐'
71
+ }
72
+
73
+ def is_hangul_syllable(ch: str) -> bool:
74
+ return len(ch) == 1 and (HANGUL_BASE <= ord(ch) <= HANGUL_END)
75
+
76
+ def decompose_syllable(ch: str) -> Tuple[str, str, str]:
77
+ code = ord(ch) - HANGUL_BASE
78
+ cho = CHO_LIST[code // (21 * 28)]
79
+ jung = JUNG_LIST[(code % (21 * 28)) // 28]
80
+ jong = JONG_LIST[code % 28]
81
+ return cho, jung, jong
82
+
83
+ def compose_syllable(cho: str, jung: str, jong: str = '') -> str:
84
+ cho_i = CHO_MAP.get(cho, 0)
85
+ jung_i = JUNG_MAP.get(jung, 0)
86
+ jong_i = JONG_MAP.get(jong, 0)
87
+ return chr(HANGUL_BASE + (cho_i * 21 * 28) + (jung_i * 28) + jong_i)
88
+
89
+ class KoreanG2PEngine:
90
+ """High-Performance Bigram Korean Grapheme-to-Phoneme Engine."""
91
+
92
+ def __init__(self):
93
+ self.irregular_dict = IRREGULAR_WORDS
94
+
95
+ def convert(self, text: str) -> str:
96
+ if not text or not text.strip():
97
+ return ''
98
+
99
+ # 1. Dictionary-based irregular word replacement
100
+ res_text = text
101
+ for k, v in self.irregular_dict.items():
102
+ if k in res_text:
103
+ res_text = res_text.replace(k, v)
104
+
105
+ # 2. Syllable-by-syllable phonological transformation
106
+ tokens = list(res_text)
107
+ n = len(tokens)
108
+ if n <= 1:
109
+ return self._neutralize_single(res_text)
110
+
111
+ i = 0
112
+ while i < n - 1:
113
+ c1, c2 = tokens[i], tokens[i+1]
114
+ if is_hangul_syllable(c1) and is_hangul_syllable(c2):
115
+ t1, t2 = self._apply_bigram_rules(c1, c2)
116
+ tokens[i] = t1
117
+ tokens[i+1] = t2
118
+ i += 1
119
+
120
+ # 3. Final word-final neutralization on last character
121
+ if n > 0 and is_hangul_syllable(tokens[-1]):
122
+ cho, jung, jong = decompose_syllable(tokens[-1])
123
+ if jong in NEUTRAL_JONG:
124
+ tokens[-1] = compose_syllable(cho, jung, NEUTRAL_JONG[jong])
125
+
126
+ return ''.join(tokens)
127
+
128
+ def _neutralize_single(self, ch: str) -> str:
129
+ if len(ch) == 1 and is_hangul_syllable(ch):
130
+ cho, jung, jong = decompose_syllable(ch)
131
+ if jong in NEUTRAL_JONG:
132
+ return compose_syllable(cho, jung, NEUTRAL_JONG[jong])
133
+ return ch
134
+
135
+ def _apply_bigram_rules(self, c1: str, c2: str) -> Tuple[str, str]:
136
+ cho1, jung1, jong1 = decompose_syllable(c1)
137
+ cho2, jung2, jong2 = decompose_syllable(c2)
138
+
139
+ if not jong1:
140
+ return c1, c2
141
+
142
+ # -------------------------------------------------------------
143
+ # Rule 1: 구개음화 (Palatalization - 제17항)
144
+ # ㄷ, ㅌ + 이, 여, 야, 유 -> ㅈ, ㅊ
145
+ # -------------------------------------------------------------
146
+ if jong1 in ('ㄷ', 'ㅌ', 'ㄾ') and cho2 == 'ㅇ' and jung2 in ('ㅣ', 'ㅑ', 'ㅕ', 'ㅛ', 'ㅠ'):
147
+ if jong1 == 'ㄷ':
148
+ return compose_syllable(cho1, jung1, ''), compose_syllable('ㅈ', jung2, jong2)
149
+ elif jong1 in ('ㅌ', 'ㄾ'):
150
+ new_jong1 = 'ㄹ' if jong1 == 'ㄾ' else ''
151
+ return compose_syllable(cho1, jung1, new_jong1), compose_syllable('ㅊ', jung2, jong2)
152
+
153
+ # -------------------------------------------------------------
154
+ # Rule 2: 격음화 / 거센소리되기 (Aspiration - 제12항)
155
+ # [ㄱ, ㄷ, ㅂ, ㅈ] + [ㅎ] -> [ㅋ, ㅌ, ㅍ, ㅊ]
156
+ # [ㅎ, ㄶ, ㅀ] + [ㄱ, ㄷ, ㅂ, ㅈ] -> [ㅋ, ㅌ, ㅍ, ㅊ]
157
+ # -------------------------------------------------------------
158
+ if cho2 == 'ㅎ':
159
+ asp_map = {'ㄱ': 'ㅋ', 'ㄷ': 'ㅌ', 'ㅂ': 'ㅍ', 'ㅈ': 'ㅊ', 'ㄺ': 'ㅋ', 'ㄼ': 'ㅍ', 'ㄵ': 'ㅊ'}
160
+ if jong1 in asp_map:
161
+ new_jong1 = 'ㄹ' if jong1 in ('ㄺ', 'ㄼ') else ('ㄴ' if jong1 == 'ㄵ' else '')
162
+ return compose_syllable(cho1, jung1, new_jong1), compose_syllable(asp_map[jong1], jung2, jong2)
163
+ elif jong1 in ('ㅅ', 'ㅆ', 'ㅊ', 'ㅌ'):
164
+ return compose_syllable(cho1, jung1, ''), compose_syllable('ㅌ', jung2, jong2)
165
+
166
+ if jong1 in ('ㅎ', 'ㄶ', 'ㅀ'):
167
+ asp_map2 = {'ㄱ': 'ㅋ', 'ㄷ': 'ㅌ', 'ㅂ': 'ㅍ', 'ㅈ': 'ㅊ', 'ㅅ': 'ㅆ'}
168
+ if cho2 in asp_map2:
169
+ new_jong1 = 'ㄴ' if jong1 == 'ㄶ' else ('ㄹ' if jong1 == 'ㅀ' else '')
170
+ return compose_syllable(cho1, jung1, new_jong1), compose_syllable(asp_map2[cho2], jung2, jong2)
171
+ elif cho2 == 'ㅇ': # ㅎ 탈락 (좋은 -> 조은)
172
+ new_jong1 = 'ㄴ' if jong1 == 'ㄶ' else ('ㄹ' if jong1 == 'ㅀ' else '')
173
+ return compose_syllable(cho1, jung1, new_jong1), compose_syllable('ㅇ', jung2, jong2)
174
+
175
+ # -------------------------------------------------------------
176
+ # Rule 3: 연음 규칙 (Liaison - 제13/14항)
177
+ # 받침 뒤에 초성 'ㅇ'(모음)이 오는 경우
178
+ # -------------------------------------------------------------
179
+ if cho2 == 'ㅇ':
180
+ if jong1 in DOUBLE_JONG_SPLIT:
181
+ j_first, j_second = DOUBLE_JONG_SPLIT[jong1]
182
+ # 겹받침 앞 글자는 남고 뒤 글자는 초성으로 이동
183
+ return compose_syllable(cho1, jung1, j_first), compose_syllable(j_second, jung2, jong2)
184
+ else:
185
+ # 홑받침 전체가 다음 음절 초성으로 이동
186
+ return compose_syllable(cho1, jung1, ''), compose_syllable(jong1, jung2, jong2)
187
+
188
+ # -------------------------------------------------------------
189
+ # Rule 4: 유음화 (Liquidization - 제20항)
190
+ # ㄴ + ㄹ -> ㄹ + ㄹ / ㄹ + ㄴ -> ㄹ + ㄹ
191
+ # -------------------------------------------------------------
192
+ if jong1 == 'ㄴ' and cho2 == 'ㄹ':
193
+ return compose_syllable(cho1, jung1, 'ㄹ'), compose_syllable('ㄹ', jung2, jong2)
194
+ if jong1 in ('ㄹ', 'ㄾ', 'ㅀ', 'ㄼ') and cho2 == 'ㄴ':
195
+ return compose_syllable(cho1, jung1, 'ㄹ'), compose_syllable('ㄹ', jung2, jong2)
196
+
197
+ # -------------------------------------------------------------
198
+ # Rule 5: 비음화 (Nasalization - 제18/19항)
199
+ # ㄱ, ㄷ, ㅂ + ㄴ, ㅁ -> ㅇ, ㄴ, ㅁ + ㄴ, ㅁ
200
+ # ㅁ, ㅇ + ㄹ -> ㅁ, ㅇ + ㄴ
201
+ # ㄱ, ㅂ + ㄹ -> ㅇ, ㅁ + ㄴ (연쇄 비음화)
202
+ # -------------------------------------------------------------
203
+ effective_jong = NEUTRAL_JONG.get(jong1, jong1)
204
+
205
+ # ㅁ, ㅇ + ㄹ -> ㅁ, ㅇ + ㄴ
206
+ if jong1 in ('ㅁ', 'ㅇ') and cho2 == 'ㄹ':
207
+ return c1, compose_syllable('ㄴ', jung2, jong2)
208
+
209
+ # ㄱ, ㅂ + ㄹ -> ㅇ, ㅁ + ㄴ
210
+ if effective_jong == 'ㄱ' and cho2 == 'ㄹ':
211
+ return compose_syllable(cho1, jung1, 'ㅇ'), compose_syllable('ㄴ', jung2, jong2)
212
+ if effective_jong == 'ㅂ' and cho2 == 'ㄹ':
213
+ return compose_syllable(cho1, jung1, 'ㅁ'), compose_syllable('ㄴ', jung2, jong2)
214
+
215
+ # ㄱ, ㄷ, ㅂ + ㄴ, ㅁ -> ㅇ, ㄴ, ㅁ
216
+ if cho2 in ('ㄴ', 'ㅁ'):
217
+ if effective_jong == 'ㄱ':
218
+ return compose_syllable(cho1, jung1, 'ㅇ'), c2
219
+ elif effective_jong == 'ㄷ':
220
+ return compose_syllable(cho1, jung1, 'ㄴ'), c2
221
+ elif effective_jong == 'ㅂ':
222
+ return compose_syllable(cho1, jung1, 'ㅁ'), c2
223
+
224
+ # -------------------------------------------------------------
225
+ # Rule 6: 경음화 / 된소리되기 (Tensification - 제23항)
226
+ # 받침 [ㄱ, ㄷ, ㅂ] + [ㄱ, ㄷ, ㅂ, ㅅ, ㅈ] -> [ㄲ, ㄸ, ㅃ, ㅆ, ㅉ]
227
+ # -------------------------------------------------------------
228
+ tense_map = {'ㄱ': 'ㄲ', 'ㄷ': 'ㄸ', 'ㅂ': 'ㅃ', 'ㅅ': 'ㅆ', 'ㅈ': 'ㅉ'}
229
+ if effective_jong in ('ㄱ', 'ㄷ', 'ㅂ') and cho2 in tense_map:
230
+ return compose_syllable(cho1, jung1, effective_jong), compose_syllable(tense_map[cho2], jung2, jong2)
231
+
232
+ # 기본 자음 앞 겹받침 단순화 (제9항)
233
+ if jong1 in NEUTRAL_JONG:
234
+ return compose_syllable(cho1, jung1, NEUTRAL_JONG[jong1]), c2
235
+
236
+ return c1, c2
237
+
238
+ _g2p_instance = KoreanG2PEngine()
239
+
240
+ def korean_text_to_phonemes(text: str) -> str:
241
+ """Public API: Convert standard Korean orthographic text into phonetic Hangul sequence."""
242
+ return _g2p_instance.convert(text)
@@ -0,0 +1,184 @@
1
+ """
2
+ Grapheme-to-Phoneme (G2P) Phonetic Tokenizer for Korean and English.
3
+ Includes Number-to-Speech Normalizer and Inline Expressive Tag Parser.
4
+ """
5
+
6
+ import re
7
+ from typing import List, Dict
8
+ from .exceptions import TTSLanguageNotSupportedError
9
+ from .g2p_korean import korean_text_to_phonemes
10
+
11
+ HANGUL_BASE = 0xAC00
12
+ HANGUL_END = 0xD7A3
13
+
14
+ CHO = [
15
+ "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ",
16
+ "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"
17
+ ]
18
+ JUNG = [
19
+ "ㅏ", "ㅐ", "ㅑ", "ㅒ", "ㅓ", "ㅔ", "ㅕ", "ㅖ", "ㅗ", "ㅘ",
20
+ "ㅙ", "ㅚ", "ㅛ", "ㅜ", "ㅝ", "ㅞ", "ㅟ", "ㅠ", "ㅡ", "ㅢ", "ㅣ"
21
+ ]
22
+ JONG = [
23
+ "", "ㄱ", "ㄲ", "ㄳ", "ㄴ", "ㄵ", "ㄶ", "ㄷ", "ㄹ", "ㄺ",
24
+ "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅁ", "ㅂ", "ㅄ", "ㅅ",
25
+ "ㅆ", "ㅇ", "ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"
26
+ ]
27
+
28
+ EXPRESSIVE_TAGS = {
29
+ "[laugh]": 1001,
30
+ "[sigh]": 1002,
31
+ "[breath]": 1003,
32
+ "[uv_break]": 1004,
33
+ "[clears_throat]": 1005,
34
+ "[pause]": 1006
35
+ }
36
+
37
+ VOCAB: List[str] = [
38
+ "_", " ", "!", "?", ",", ".", "~", "-",
39
+ *CHO, *JUNG, *[j for j in JONG if j],
40
+ *"abcdefghijklmnopqrstuvwxyz"
41
+ ]
42
+ VOCAB_TO_ID: Dict[str, int] = {sym: idx for idx, sym in enumerate(VOCAB)}
43
+ PAD_ID: int = VOCAB_TO_ID["_"]
44
+ SPACE_ID: int = VOCAB_TO_ID[" "]
45
+
46
+ KOREAN_DIGITS = ["", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구"]
47
+ SMALL_UNITS = ["", "십", "백", "천"]
48
+ BIG_UNITS = ["", "만", "억", "조", "경"]
49
+
50
+ ENGLISH_WORDS = {
51
+ "0": "zero", "1": "one", "2": "two", "3": "three", "4": "four",
52
+ "5": "five", "6": "six", "7": "seven", "8": "eight", "9": "nine",
53
+ "10": "ten", "11": "eleven", "12": "twelve", "13": "thirteen", "14": "fourteen",
54
+ "15": "fifteen", "16": "sixteen", "17": "seventeen", "18": "eighteen", "19": "nineteen",
55
+ "20": "twenty", "30": "thirty", "40": "forty", "50": "fifty",
56
+ "60": "sixty", "70": "seventy", "80": "eighty", "90": "ninety",
57
+ "100": "hundred", "1000": "thousand"
58
+ }
59
+
60
+ def decompose_hangul(char: str) -> List[str]:
61
+ code = ord(char)
62
+ if HANGUL_BASE <= code <= HANGUL_END:
63
+ offset = code - HANGUL_BASE
64
+ cho_idx = offset // (21 * 28)
65
+ jung_idx = (offset % (21 * 28)) // 28
66
+ jong_idx = offset % 28
67
+ res = [CHO[cho_idx], JUNG[jung_idx]]
68
+ if jong_idx > 0:
69
+ res.append(JONG[jong_idx])
70
+ return res
71
+ return [char]
72
+
73
+ def _convert_4digits_korean(chunk: str) -> str:
74
+ num = int(chunk)
75
+ if num == 0:
76
+ return ""
77
+ res = []
78
+ str_num = str(num).zfill(4)
79
+ for i, ch in enumerate(str_num):
80
+ d = int(ch)
81
+ if d > 0:
82
+ unit = SMALL_UNITS[3 - i]
83
+ if d == 1 and unit != "":
84
+ res.append(unit)
85
+ else:
86
+ res.append(KOREAN_DIGITS[d] + unit)
87
+ return "".join(res)
88
+
89
+ def number_to_korean_sino(num_str: str) -> str:
90
+ """Convert integer string to authentic Sino-Korean place-value numerals (e.g. 1234 -> 천이백삼십사, 10000 -> 만)."""
91
+ try:
92
+ n = int(num_str)
93
+ except ValueError:
94
+ return num_str
95
+ if n == 0:
96
+ return "영"
97
+ rev_str = str(n)[::-1]
98
+ chunks = [rev_str[i:i+4][::-1] for i in range(0, len(rev_str), 4)]
99
+ parts = []
100
+ for i, chunk in enumerate(chunks):
101
+ c_korean = _convert_4digits_korean(chunk)
102
+ if c_korean:
103
+ unit = BIG_UNITS[i]
104
+ # 10000일 때 '일만' 대신 '만' (단, 210000 -> 이십일만)
105
+ if c_korean == "일" and unit != "" and len(chunks) == i + 1:
106
+ parts.append(unit)
107
+ else:
108
+ parts.append(c_korean + unit)
109
+ return "".join(reversed(parts))
110
+
111
+ def normalize_numbers_korean(text: str) -> str:
112
+ """Convert digit sequences into spoken Korean place-value numerals."""
113
+ return re.sub(r"\d+", lambda m: number_to_korean_sino(m.group(0)), text)
114
+
115
+ def normalize_numbers_english(text: str) -> str:
116
+ """Convert digit sequences into spoken English words."""
117
+ def _en_repl(m):
118
+ num_str = m.group(0)
119
+ if num_str in ENGLISH_WORDS:
120
+ return " " + ENGLISH_WORDS[num_str] + " "
121
+ # Digit-by-digit for phone numbers / codes
122
+ return " " + " ".join(ENGLISH_WORDS.get(d, d) for d in num_str) + " "
123
+ return re.sub(r"\d+", _en_repl, text)
124
+
125
+ class PhoneticTokenizer:
126
+ def __init__(self, language: str = "ko"):
127
+ self.language = language.lower()
128
+ self.vocab = VOCAB
129
+ if self.language not in ["ko", "korean", "en", "english"]:
130
+ raise TTSLanguageNotSupportedError(f"Language '{language}' is not supported. Supported: ['ko', 'en']")
131
+
132
+ def normalize_text(self, text: str) -> str:
133
+ if not text or not text.strip():
134
+ return ""
135
+ # 1. Normalize linebreaks and tabs
136
+ text = re.sub(r"[\r\n\t]+", " ", text)
137
+
138
+ # 2. Digits to spoken words
139
+ if self.language in ["ko", "korean"]:
140
+ text = normalize_numbers_korean(text)
141
+ text = korean_text_to_phonemes(text)
142
+ else:
143
+ text = normalize_numbers_english(text)
144
+
145
+ # 3. Collapse multiple spaces
146
+ text = re.sub(r"\s{2,}", " ", text)
147
+ return text.strip()
148
+
149
+ def tokenize(self, text: str) -> List[int]:
150
+ normalized = self.normalize_text(text)
151
+ if not normalized:
152
+ return []
153
+
154
+ # Parse expressive tags first
155
+ tag_pattern = re.compile(r"(\[[a-zA-Z_]+\])")
156
+ parts = tag_pattern.split(normalized)
157
+
158
+ tokens: List[int] = []
159
+ for part in parts:
160
+ if not part:
161
+ continue
162
+ if part in EXPRESSIVE_TAGS:
163
+ tokens.append(EXPRESSIVE_TAGS[part])
164
+ continue
165
+
166
+ if self.language in ["ko", "korean"]:
167
+ for char in part:
168
+ if char == " ":
169
+ tokens.append(SPACE_ID)
170
+ elif char in [".", ",", "!", "?", "~", "-"]:
171
+ if char in VOCAB_TO_ID:
172
+ tokens.append(VOCAB_TO_ID[char])
173
+ else:
174
+ jamos = decompose_hangul(char)
175
+ for j in jamos:
176
+ if j in VOCAB_TO_ID:
177
+ tokens.append(VOCAB_TO_ID[j])
178
+ else: # en
179
+ for char in part.lower():
180
+ if char in VOCAB_TO_ID:
181
+ tokens.append(VOCAB_TO_ID[char])
182
+
183
+ return tokens
184
+
@@ -0,0 +1,25 @@
1
+ """
2
+ [DEPRECATED] This file is deprecated. 12-Stage Vulkan diagnostics are 100% delegated to ameva-vulkan-runtime.
3
+ """
4
+ from typing import Dict, Any
5
+
6
+ class VulkanDoctor:
7
+ """Legacy compatibility bridge delegating directly to ameva-vulkan-runtime."""
8
+ def __init__(self):
9
+ self.is_vulkan_available = False
10
+ self.ameva_runtime_bound = True
11
+ self.report = None
12
+
13
+ def probe_all(self) -> Dict[str, Any]:
14
+ try:
15
+ import ameva_vulkan_runtime as avr
16
+ doc = avr.Doctor()
17
+ rep = doc.run_self_test(verbose=False)
18
+ return {
19
+ "overall_success": getattr(rep, "overall_success", False),
20
+ "passed_stages": getattr(rep, "passed_stages", 0),
21
+ "recommended_backend": getattr(rep, "recommended_backend", "cpu"),
22
+ "status": "BOUND_AMEVA_VULKAN"
23
+ }
24
+ except Exception as e:
25
+ return {"overall_success": False, "passed_stages": 0, "status": "FALLBACK_CPU", "error": str(e)}
package/test_cli.wav ADDED
Binary file
@@ -0,0 +1,29 @@
1
+ """
2
+ Expressive Presets & Non-Verbal Acoustic Tokens Audit Suite for termux-tts.
3
+ Tests Fast, Balanced, Expressive, Ultra presets and [laugh], [sigh], [breath], [clears_throat] tags.
4
+ """
5
+
6
+ import time
7
+ import pytest
8
+ import numpy as np
9
+ import termux_tts as tts
10
+ from termux_tts.engine import QUALITY_PRESETS, load
11
+
12
+ def test_all_four_presets_synthesis():
13
+ for preset_name in ["fast", "balanced", "expressive", "ultra"]:
14
+ t0 = time.perf_counter()
15
+ with load(language="ko", preset=preset_name) as engine:
16
+ res = engine.synthesize(f"현재 {preset_name} 품질 프리셋으로 음성을 합성 중입니다.")
17
+ assert res.preset == preset_name
18
+ assert res.sample_rate == QUALITY_PRESETS[preset_name]["sample_rate"]
19
+ assert res.duration_sec > 0.3
20
+ assert len(res.wav_bytes) > 1000
21
+ print(f"\n[PASS PRESET: {preset_name:10s}] Duration={res.duration_sec:.2f}s | Latency={res.elapsed_ms:.1f}ms | Rate={res.sample_rate}Hz")
22
+
23
+ def test_expressive_breath_and_laugh_tokens():
24
+ expressive_text = "[clears_throat] 으흠! 안녕하세요 [laugh] 하하하! 오늘 날씨가 참 좋습니다 [sigh] 휴... [breath]"
25
+ with load(language="ko", preset="expressive") as engine:
26
+ res = engine.synthesize(expressive_text, output="expressive_demo.wav")
27
+ assert res.duration_sec > 1.0
28
+ assert res.sample_rate == 24000
29
+ print(f"\n[PASS EXPRESSIVE SYNTHESIS] Synthesized in {res.elapsed_ms:.1f}ms | Duration={res.duration_sec:.2f}s")
@@ -0,0 +1,73 @@
1
+ """
2
+ Unit and Granular Test Suite for Korean Grapheme-to-Phoneme (G2P) Engine.
3
+ Validates adherence to National Institute of Korean Language Standard Pronunciation Rules.
4
+ """
5
+
6
+ import pytest
7
+ from termux_tts.g2p_korean import korean_text_to_phonemes, KoreanG2PEngine
8
+ from termux_tts.tokenizer import PhoneticTokenizer
9
+
10
+ def test_palatalization_rule():
11
+ """Verify 구개음화 (제17항): ㄷ, ㅌ + ㅣ -> ㅈ, ㅊ"""
12
+ assert korean_text_to_phonemes("굳이") == "구지"
13
+ assert korean_text_to_phonemes("같이") == "가치"
14
+ assert korean_text_to_phonemes("미닫이") == "미다지"
15
+ assert korean_text_to_phonemes("붙이다") == "부치다"
16
+ assert korean_text_to_phonemes("핥이다") == "할치다"
17
+
18
+ def test_aspiration_rule():
19
+ """Verify 격음화 / 거센소리되기 (제12항): ㄱ,ㄷ,ㅂ,ㅈ + ㅎ -> ㅋ,ㅌ,ㅍ,ㅊ"""
20
+ assert korean_text_to_phonemes("축하") == "추카"
21
+ assert korean_text_to_phonemes("좋다") == "조타"
22
+ assert korean_text_to_phonemes("입학") == "이팍"
23
+ assert korean_text_to_phonemes("맞히다") == "마치다"
24
+ assert korean_text_to_phonemes("좋은") == "조은"
25
+
26
+ def test_nasalization_rule():
27
+ """Verify 비음화 (제18/19항): ㄱ,ㄷ,ㅂ + ㄴ,ㅁ -> ㅇ,ㄴ,ㅁ & ㅁ,ㅇ + ㄹ -> ㄴ"""
28
+ assert korean_text_to_phonemes("국물") == "궁물"
29
+ assert korean_text_to_phonemes("닫는") == "단는"
30
+ assert korean_text_to_phonemes("밥먹다") == "밤먹따"
31
+ assert korean_text_to_phonemes("독립") == "동닙"
32
+ assert korean_text_to_phonemes("백로") == "뱅노"
33
+ assert korean_text_to_phonemes("종로") == "종노"
34
+ assert korean_text_to_phonemes("심리") == "심니"
35
+
36
+ def test_liquidization_rule():
37
+ """Verify 유음화 (제20항): ㄴ+ㄹ, ㄹ+ㄴ -> ㄹ+ㄹ"""
38
+ assert korean_text_to_phonemes("신라") == "실라"
39
+ assert korean_text_to_phonemes("난로") == "날로"
40
+ assert korean_text_to_phonemes("칼날") == "칼랄"
41
+ assert korean_text_to_phonemes("줄넘기") == "줄럼기"
42
+
43
+ def test_tensification_rule():
44
+ """Verify 경음화 / 된소리되기 (제23항): ㄱ,ㄷ,ㅂ + ㄱ,ㄷ,ㅂ,ㅅ,ㅈ -> ㄲ,ㄸ,ㅃ,ㅆ,ㅉ"""
45
+ assert korean_text_to_phonemes("국밥") == "국빱"
46
+ assert korean_text_to_phonemes("학교") == "학꾜"
47
+ assert korean_text_to_phonemes("옷고름") == "옫꼬름"
48
+ assert korean_text_to_phonemes("옆집") == "엽찝"
49
+
50
+ def test_liaison_rule():
51
+ """Verify 연음 규칙 (제13/14항): 받침 + 모음"""
52
+ assert korean_text_to_phonemes("옷이") == "오시"
53
+ assert korean_text_to_phonemes("닭을") == "달글"
54
+ assert korean_text_to_phonemes("값을") == "갑슬"
55
+
56
+ def test_sino_korean_exceptions():
57
+ """Verify 특수 한자어 및 복합어 사전 예외 발음"""
58
+ assert korean_text_to_phonemes("생산량") == "생산냥"
59
+ assert korean_text_to_phonemes("결단력") == "결딴녁"
60
+ assert korean_text_to_phonemes("금융") == "금늉"
61
+ assert korean_text_to_phonemes("솜이불") == "솜니불"
62
+ assert korean_text_to_phonemes("맨입") == "맨닙"
63
+ assert korean_text_to_phonemes("꽃잎") == "꼰닙"
64
+
65
+ def test_tokenizer_integration_with_g2p():
66
+ """Verify PhoneticTokenizer end-to-end normalization with G2P and numbers."""
67
+ tok = PhoneticTokenizer(language="ko")
68
+ # 100원 -> 백원 -> 배권 (연음/자음동화)
69
+ norm = tok.normalize_text("100원 국물 같이 굳이 먹자")
70
+ assert "배권" in norm or "백원" in norm
71
+ assert "궁물" in norm
72
+ assert "가치" in norm
73
+ assert "구지" in norm