termux-tts 1.4.2 → 1.4.4
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/CHANGELOG.md +21 -0
- package/README.md +81 -381
- package/README.pypi.md +26 -410
- package/doc.config.yaml +101 -37
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/termux_tts/__init__.py +11 -1
- package/termux_tts/audio.py +6 -3
- package/termux_tts/cli.py +27 -10
- package/termux_tts/engine.py +73 -7
- package/termux_tts/engine_multilingual.py +374 -0
- package/termux_tts/engine_sherpa.py +2 -1
- package/termux_tts/engine_sherpa_capi.py +491 -0
- package/termux_tts/hardware.py +34 -1
- package/termux_tts/installer.py +213 -11
- package/termux_tts/script_classifier.py +253 -0
- package/termux_tts/tokenizer.py +3 -1
package/termux_tts/installer.py
CHANGED
|
@@ -9,10 +9,34 @@ import urllib.request
|
|
|
9
9
|
import shutil
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
def get_candidate_vulkan_binary_urls():
|
|
13
|
+
"""Generate dynamic candidate endpoints for Vulkan binary provisioner."""
|
|
14
|
+
try:
|
|
15
|
+
from . import __version__
|
|
16
|
+
except Exception:
|
|
17
|
+
__version__ = "1.4.4"
|
|
18
|
+
|
|
19
|
+
urls = []
|
|
20
|
+
custom_tag = os.environ.get("TERMUX_TTS_RELEASE_TAG", "").strip()
|
|
21
|
+
custom_base = os.environ.get("TERMUX_TTS_RELEASE_BASE", "").strip()
|
|
22
|
+
|
|
23
|
+
if custom_base:
|
|
24
|
+
urls.append(f"{custom_base.rstrip('/')}/sherpa-ncnn-offline-tts-vulkan-arm64.tar.gz")
|
|
25
|
+
if custom_tag:
|
|
26
|
+
tag = custom_tag if custom_tag.startswith("v") else f"v{custom_tag}"
|
|
27
|
+
urls.append(f"https://github.com/uno-km/termux-tts/releases/download/{tag}/sherpa-ncnn-offline-tts-vulkan-arm64.tar.gz")
|
|
28
|
+
|
|
29
|
+
# 1. termux-tts current version SSOT
|
|
30
|
+
current_tag = f"v{__version__}"
|
|
31
|
+
urls.append(f"https://github.com/uno-km/termux-tts/releases/download/{current_tag}/sherpa-ncnn-offline-tts-vulkan-arm64.tar.gz")
|
|
32
|
+
|
|
33
|
+
# 2. termux-tts latest release (verified HTTP 200)
|
|
34
|
+
urls.append("https://github.com/uno-km/termux-tts/releases/latest/download/sherpa-ncnn-offline-tts-vulkan-arm64.tar.gz")
|
|
35
|
+
|
|
36
|
+
# 3. Companion release fallback (verified HTTP 200)
|
|
37
|
+
urls.append("https://github.com/uno-km/termux-sherpa-ncnn/releases/download/v1.0.0-vulkan/sherpa-ncnn-offline-tts-vulkan-arm64.tar.gz")
|
|
38
|
+
|
|
39
|
+
return urls
|
|
16
40
|
|
|
17
41
|
MODEL_REGISTRY = {
|
|
18
42
|
"high": {
|
|
@@ -39,17 +63,90 @@ MODEL_REGISTRY = {
|
|
|
39
63
|
}
|
|
40
64
|
}
|
|
41
65
|
|
|
66
|
+
OFFICIAL_NEURAL_MODELS = {
|
|
67
|
+
"ko": {
|
|
68
|
+
"name": "vits-mimic3-ko_KO-kss_low",
|
|
69
|
+
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-mimic3-ko_KO-kss_low.tar.bz2",
|
|
70
|
+
"repo": "csukuangfj/vits-mimic3-ko_KO-kss_low",
|
|
71
|
+
"language_name": "Korean",
|
|
72
|
+
"description": "Korean VITS Mimic3 KSS Studio ONNX Model (~45MB)",
|
|
73
|
+
},
|
|
74
|
+
"en": {
|
|
75
|
+
"name": "vits-piper-en_US-lessac-medium",
|
|
76
|
+
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-en_US-lessac-medium.tar.bz2",
|
|
77
|
+
"repo": "csukuangfj/vits-piper-en_US-lessac-medium",
|
|
78
|
+
"language_name": "English",
|
|
79
|
+
"description": "English VITS Piper Lessac Medium ONNX Model (~55MB)",
|
|
80
|
+
},
|
|
81
|
+
"ja": {
|
|
82
|
+
"name": "vits-piper-ja_JP-hina-medium",
|
|
83
|
+
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-ja_JP-hina-medium.tar.bz2",
|
|
84
|
+
"repo": "csukuangfj/vits-piper-ja_JP-hina-medium",
|
|
85
|
+
"language_name": "Japanese",
|
|
86
|
+
"description": "Japanese VITS Piper Hina Medium ONNX Model (~50MB)",
|
|
87
|
+
},
|
|
88
|
+
"zh": {
|
|
89
|
+
"name": "vits-zh-aishell3",
|
|
90
|
+
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-zh-aishell3.tar.bz2",
|
|
91
|
+
"repo": "csukuangfj/vits-zh-aishell3",
|
|
92
|
+
"language_name": "Chinese (Mandarin)",
|
|
93
|
+
"description": "Chinese VITS AISHELL-3 Multi-Speaker ONNX Model (~65MB)",
|
|
94
|
+
},
|
|
95
|
+
"hi": {
|
|
96
|
+
"name": "vits-piper-hi_IN-swara-medium",
|
|
97
|
+
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-hi_IN-swara-medium.tar.bz2",
|
|
98
|
+
"repo": "csukuangfj/vits-piper-hi_IN-swara-medium",
|
|
99
|
+
"language_name": "Hindi",
|
|
100
|
+
"description": "Hindi VITS Piper Swara Medium ONNX Model (~55MB)",
|
|
101
|
+
},
|
|
102
|
+
"ru": {
|
|
103
|
+
"name": "vits-piper-ru_RU-dmitri-medium",
|
|
104
|
+
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-ru_RU-dmitri-medium.tar.bz2",
|
|
105
|
+
"repo": "csukuangfj/vits-piper-ru_RU-dmitri-medium",
|
|
106
|
+
"language_name": "Russian",
|
|
107
|
+
"description": "Russian VITS Piper Dmitri Medium ONNX Model (~55MB)",
|
|
108
|
+
},
|
|
109
|
+
"es": {
|
|
110
|
+
"name": "vits-piper-es_ES-davefx-medium",
|
|
111
|
+
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-es_ES-davefx-medium.tar.bz2",
|
|
112
|
+
"repo": "csukuangfj/vits-piper-es_ES-davefx-medium",
|
|
113
|
+
"language_name": "Spanish",
|
|
114
|
+
"description": "Spanish VITS Piper Davefx Medium ONNX Model (~50MB)",
|
|
115
|
+
},
|
|
116
|
+
"fr": {
|
|
117
|
+
"name": "vits-piper-fr_FR-siwis-medium",
|
|
118
|
+
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-fr_FR-siwis-medium.tar.bz2",
|
|
119
|
+
"repo": "csukuangfj/vits-piper-fr_FR-siwis-medium",
|
|
120
|
+
"language_name": "French",
|
|
121
|
+
"description": "French VITS Piper Siwis Medium ONNX Model (~50MB)",
|
|
122
|
+
},
|
|
123
|
+
"de": {
|
|
124
|
+
"name": "vits-piper-de_DE-thorsten-medium",
|
|
125
|
+
"url": "https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-de_DE-thorsten-medium.tar.bz2",
|
|
126
|
+
"repo": "csukuangfj/vits-piper-de_DE-thorsten-medium",
|
|
127
|
+
"language_name": "German",
|
|
128
|
+
"description": "German VITS Piper Thorsten Medium ONNX Model (~50MB)",
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
42
132
|
def get_install_paths():
|
|
43
|
-
home = Path.home()
|
|
44
|
-
bin_dir = home / ".local" / "bin"
|
|
45
|
-
cache_dir = home / ".cache" / "termux-tts" / "models"
|
|
133
|
+
home = Path.home().resolve()
|
|
134
|
+
bin_dir = (home / ".local" / "bin").resolve()
|
|
135
|
+
cache_dir = (home / ".cache" / "termux-tts" / "models").resolve()
|
|
136
|
+
models_tts_dir = (home / "models" / "tts").resolve()
|
|
46
137
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
47
138
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
139
|
+
models_tts_dir.mkdir(parents=True, exist_ok=True)
|
|
48
140
|
return bin_dir, cache_dir
|
|
49
141
|
|
|
50
142
|
def download_with_progress(url: str, dest_path: Path, label: str):
|
|
143
|
+
try:
|
|
144
|
+
from . import __version__
|
|
145
|
+
except Exception:
|
|
146
|
+
__version__ = "1.4.4"
|
|
147
|
+
|
|
51
148
|
print(f" [DOWNLOADING] {label}...")
|
|
52
|
-
req = urllib.request.Request(url, headers={"User-Agent": "termux-tts-installer/
|
|
149
|
+
req = urllib.request.Request(url, headers={"User-Agent": f"termux-tts-installer/{__version__} (Android; ARM64)"})
|
|
53
150
|
with urllib.request.urlopen(req) as resp, open(dest_path, "wb") as out_f:
|
|
54
151
|
total = int(resp.headers.get("Content-Length", 0))
|
|
55
152
|
downloaded = 0
|
|
@@ -69,13 +166,30 @@ def download_with_progress(url: str, dest_path: Path, label: str):
|
|
|
69
166
|
def install_vulkan_binary(force: bool = False) -> Path:
|
|
70
167
|
bin_dir, _ = get_install_paths()
|
|
71
168
|
binary_path = bin_dir / "sherpa-ncnn-offline-tts"
|
|
169
|
+
prefix_bin = Path(os.environ.get("PREFIX", "/data/data/com.termux/files/usr")) / "bin"
|
|
72
170
|
|
|
73
171
|
if binary_path.exists() and not force:
|
|
74
172
|
print(f" [OK] Pre-compiled Vulkan binary already exists: {binary_path}")
|
|
75
173
|
return binary_path
|
|
76
174
|
|
|
77
175
|
tar_path = bin_dir / "sherpa-vulkan.tar.gz"
|
|
78
|
-
|
|
176
|
+
candidate_urls = get_candidate_vulkan_binary_urls()
|
|
177
|
+
download_success = False
|
|
178
|
+
|
|
179
|
+
for url in candidate_urls:
|
|
180
|
+
try:
|
|
181
|
+
download_with_progress(url, tar_path, f"ARM64 Vulkan Binary ({url})")
|
|
182
|
+
if tar_path.exists() and tar_path.stat().st_size > 500 * 1024:
|
|
183
|
+
download_success = True
|
|
184
|
+
break
|
|
185
|
+
except Exception as dl_err:
|
|
186
|
+
print(f" [-] Candidate URL failed ({url}): {dl_err}")
|
|
187
|
+
if tar_path.exists():
|
|
188
|
+
tar_path.unlink(missing_ok=True)
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
if not download_success:
|
|
192
|
+
raise RuntimeError("Failed to download sherpa-ncnn-offline-tts from any candidate endpoints.")
|
|
79
193
|
|
|
80
194
|
print(" [EXTRACTING] Installing binary to ~/.local/bin...")
|
|
81
195
|
with tarfile.open(tar_path, "r:gz") as tar:
|
|
@@ -83,6 +197,17 @@ def install_vulkan_binary(force: bool = False) -> Path:
|
|
|
83
197
|
|
|
84
198
|
tar_path.unlink(missing_ok=True)
|
|
85
199
|
binary_path.chmod(0o755)
|
|
200
|
+
|
|
201
|
+
# Dual-path installation to PREFIX/bin if writable
|
|
202
|
+
try:
|
|
203
|
+
if prefix_bin.exists() and os.access(prefix_bin, os.W_OK):
|
|
204
|
+
target_prefix_bin = prefix_bin / "sherpa-ncnn-offline-tts"
|
|
205
|
+
shutil.copy2(binary_path, target_prefix_bin)
|
|
206
|
+
target_prefix_bin.chmod(0o755)
|
|
207
|
+
print(f" [SYMLINK/COPY] Linked to {target_prefix_bin}")
|
|
208
|
+
except OSError:
|
|
209
|
+
pass
|
|
210
|
+
|
|
86
211
|
print(f" [SUCCESS] Installed: {binary_path}")
|
|
87
212
|
return binary_path
|
|
88
213
|
|
|
@@ -109,9 +234,68 @@ def install_vits_model(tier: str = "high", force: bool = False) -> Path:
|
|
|
109
234
|
print(f" [SUCCESS] Model installed at: {model_dir}")
|
|
110
235
|
return model_dir
|
|
111
236
|
|
|
112
|
-
def
|
|
237
|
+
def provision_neural_model_archive(language: str, force: bool = False) -> Path:
|
|
238
|
+
"""
|
|
239
|
+
On-Demand Auto-Provisioner for official neural speech models.
|
|
240
|
+
Downloads official pre-compiled model archives (.tar.bz2) and extracts directly into cache.
|
|
241
|
+
"""
|
|
242
|
+
from .script_classifier import normalize_language_code
|
|
243
|
+
lang = normalize_language_code(language)
|
|
244
|
+
if lang not in OFFICIAL_NEURAL_MODELS:
|
|
245
|
+
from .exceptions import TTSModelLoadError
|
|
246
|
+
raise TTSModelLoadError(
|
|
247
|
+
f"[FAIL-FAST] No official automated model package registered for language '{lang}'.\n"
|
|
248
|
+
f"Available automated packages: {list(OFFICIAL_NEURAL_MODELS.keys())}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
cfg = OFFICIAL_NEURAL_MODELS[lang]
|
|
252
|
+
_, cache_dir = get_install_paths()
|
|
253
|
+
target_dir = cache_dir / cfg["name"]
|
|
254
|
+
unified_tts_dir = Path.home() / "models" / "tts"
|
|
255
|
+
|
|
256
|
+
if target_dir.is_dir() and not force:
|
|
257
|
+
onnx_files = list(target_dir.glob("*.onnx"))
|
|
258
|
+
if onnx_files and (target_dir / "tokens.txt").exists() and (target_dir / "espeak-ng-data").exists():
|
|
259
|
+
# Ensure symlink in ~/models/tts/
|
|
260
|
+
try:
|
|
261
|
+
link_dest = unified_tts_dir / cfg["name"]
|
|
262
|
+
if not link_dest.exists() and not link_dest.is_symlink():
|
|
263
|
+
link_dest.symlink_to(target_dir)
|
|
264
|
+
except OSError:
|
|
265
|
+
pass
|
|
266
|
+
return target_dir
|
|
267
|
+
|
|
268
|
+
print(f"\n[termux-tts runtime] Auto-provisioning {cfg['description']}...")
|
|
269
|
+
archive_path = cache_dir / f"{cfg['name']}.tar.bz2"
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
download_with_progress(cfg["url"], archive_path, cfg["name"])
|
|
273
|
+
print(f" [EXTRACTING] Unpacking model archive into {cache_dir}...")
|
|
274
|
+
with tarfile.open(archive_path, "r:bz2") as tar:
|
|
275
|
+
tar.extractall(path=cache_dir)
|
|
276
|
+
archive_path.unlink(missing_ok=True)
|
|
277
|
+
|
|
278
|
+
# Link to ~/models/tts/ as unified SSOT
|
|
279
|
+
try:
|
|
280
|
+
link_dest = unified_tts_dir / cfg["name"]
|
|
281
|
+
if not link_dest.exists() and not link_dest.is_symlink():
|
|
282
|
+
link_dest.symlink_to(target_dir)
|
|
283
|
+
except OSError:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
print(f" [SUCCESS] Model provisioned: {target_dir}")
|
|
287
|
+
return target_dir
|
|
288
|
+
except Exception as err:
|
|
289
|
+
if archive_path.exists():
|
|
290
|
+
archive_path.unlink(missing_ok=True)
|
|
291
|
+
from .exceptions import TTSModelLoadError
|
|
292
|
+
raise TTSModelLoadError(
|
|
293
|
+
f"[FAIL-FAST] Failed to auto-provision neural model '{cfg['name']}': {err}"
|
|
294
|
+
) from err
|
|
295
|
+
|
|
296
|
+
def run_installation(tier: str = "high", models: str = "default", force: bool = False, play: bool = True):
|
|
113
297
|
print("=" * 70)
|
|
114
|
-
print(" TERMUX-TTS
|
|
298
|
+
print(" TERMUX-TTS AUTOMATED PROVISIONER (BATTERIES-INCLUDED RUNTIME)")
|
|
115
299
|
print("=" * 70)
|
|
116
300
|
|
|
117
301
|
# 1. Install pre-compiled Vulkan binary
|
|
@@ -119,6 +303,24 @@ def run_installation(tier: str = "high", force: bool = False, play: bool = True)
|
|
|
119
303
|
|
|
120
304
|
# 2. Install VITS model
|
|
121
305
|
model_path = install_vits_model(tier=tier, force=force)
|
|
306
|
+
|
|
307
|
+
# 2b. Install Multilingual VITS ONNX Models
|
|
308
|
+
# Default is ONLY Korean (ko) & English (en) for ultra-lightweight initial setup!
|
|
309
|
+
raw_models = models.strip().lower() if models else "default"
|
|
310
|
+
if raw_models in ("default", "base", "core"):
|
|
311
|
+
langs_to_install = ["ko", "en"]
|
|
312
|
+
elif raw_models == "all":
|
|
313
|
+
langs_to_install = list(OFFICIAL_NEURAL_MODELS.keys())
|
|
314
|
+
else:
|
|
315
|
+
# User specified specific language(s) like "hi" or "ja,zh"
|
|
316
|
+
langs_to_install = [l.strip() for l in raw_models.split(",") if l.strip()]
|
|
317
|
+
|
|
318
|
+
print(f"\n[MULTILINGUAL] Provisioning Neural Speech Models: {langs_to_install}...")
|
|
319
|
+
for lang in langs_to_install:
|
|
320
|
+
try:
|
|
321
|
+
provision_neural_model_archive(lang, force=force)
|
|
322
|
+
except Exception as e:
|
|
323
|
+
print(f" [-] Multilingual {lang} provisioning note: {e}")
|
|
122
324
|
|
|
123
325
|
# 3. Environment check
|
|
124
326
|
vulkan_lib = Path("/system/lib64/libvulkan.so")
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extensible Unicode Script Classifier and Multilingual Tokenizer.
|
|
3
|
+
Supports Korean (ko), English/Latin (en), Japanese (ja), Chinese (zh),
|
|
4
|
+
and dynamic extension for future languages.
|
|
5
|
+
Adheres to strict Zero-Silent-Fallback and AOSF-ENG-STD-2026 Governance.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import List, Tuple, Optional, Set, Dict, Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class LanguageChunk:
|
|
16
|
+
text: str
|
|
17
|
+
language: str
|
|
18
|
+
pause_after: float
|
|
19
|
+
is_affix: bool = False
|
|
20
|
+
|
|
21
|
+
def __repr__(self) -> str:
|
|
22
|
+
return f"LanguageChunk(lang='{self.language}', text='{self.text}', pause={self.pause_after:.3f}s, affix={self.is_affix})"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ScriptRegistry:
|
|
26
|
+
"""
|
|
27
|
+
Extensible Unicode Range Registry for multi-script linguistic detection.
|
|
28
|
+
Allows dynamic registration of new languages without modifying core dispatch logic.
|
|
29
|
+
"""
|
|
30
|
+
_CUSTOM_MATCHERS: Dict[str, Callable[[int], bool]] = {}
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def register_language(cls, lang_code: str, matcher: Callable[[int], bool]) -> None:
|
|
34
|
+
cls._CUSTOM_MATCHERS[lang_code.lower()] = matcher
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def classify_code_point(cls, code: int) -> str:
|
|
38
|
+
# 1. Custom registered languages
|
|
39
|
+
for lang_code, matcher in cls._CUSTOM_MATCHERS.items():
|
|
40
|
+
if matcher(code):
|
|
41
|
+
return lang_code.lower()
|
|
42
|
+
|
|
43
|
+
# 2. Korean (Hangul Syllables, Jamo, Compatibility Jamo, Extended Jamo A/B)
|
|
44
|
+
if ((0xAC00 <= code <= 0xD7A3) or
|
|
45
|
+
(0x1100 <= code <= 0x11FF) or
|
|
46
|
+
(0x3130 <= code <= 0x318F) or
|
|
47
|
+
(0xA960 <= code <= 0xA97F) or
|
|
48
|
+
(0xD7B0 <= code <= 0xD7FF)):
|
|
49
|
+
return "ko"
|
|
50
|
+
|
|
51
|
+
# 3. Japanese Hiragana & Katakana (distinctly Japanese)
|
|
52
|
+
if ((0x3040 <= code <= 0x309F) or # Hiragana
|
|
53
|
+
(0x30A0 <= code <= 0x30FF) or # Katakana
|
|
54
|
+
(0x31F0 <= code <= 0x31FF)): # Katakana Phonetic Extensions
|
|
55
|
+
return "ja"
|
|
56
|
+
|
|
57
|
+
# 4. English & Latin Alphabet (Basic Latin & Latin-1 Supplement)
|
|
58
|
+
if ((0x0041 <= code <= 0x005A) or # A-Z
|
|
59
|
+
(0x0061 <= code <= 0x007A) or # a-z
|
|
60
|
+
(0x00C0 <= code <= 0x024F)): # Latin Extended-A & B
|
|
61
|
+
return "en"
|
|
62
|
+
|
|
63
|
+
# 5. CJK Unified Ideographs (Common Hanzi/Kanji/Hanja)
|
|
64
|
+
if ((0x4E00 <= code <= 0x9FFF) or
|
|
65
|
+
(0x3400 <= code <= 0x4DBF) or
|
|
66
|
+
(0x20000 <= code <= 0x2A6DF)):
|
|
67
|
+
return "cjk_ideograph"
|
|
68
|
+
|
|
69
|
+
# 6. Hindi (Devanagari script)
|
|
70
|
+
if 0x0900 <= code <= 0x097F:
|
|
71
|
+
return "hi"
|
|
72
|
+
|
|
73
|
+
# 7. Russian (Cyrillic script)
|
|
74
|
+
if (0x0400 <= code <= 0x04FF) or (0x0500 <= code <= 0x052F):
|
|
75
|
+
return "ru"
|
|
76
|
+
|
|
77
|
+
# 8. Arabic script
|
|
78
|
+
if (0x0600 <= code <= 0x06FF) or (0x0750 <= code <= 0x077F):
|
|
79
|
+
return "ar"
|
|
80
|
+
|
|
81
|
+
# 9. Spaces, Controls
|
|
82
|
+
if code in (0x20, 0x09, 0x0A, 0x0D):
|
|
83
|
+
return "space"
|
|
84
|
+
|
|
85
|
+
# 10. Punctuations
|
|
86
|
+
ch = chr(code)
|
|
87
|
+
if ch in ".?!":
|
|
88
|
+
return "punct_sentence"
|
|
89
|
+
elif ch in ",;:":
|
|
90
|
+
return "punct_clause"
|
|
91
|
+
elif ch in "\"'`-~…—()[]{}":
|
|
92
|
+
return "punct_inline"
|
|
93
|
+
elif 0x0030 <= code <= 0x0039:
|
|
94
|
+
return "digit"
|
|
95
|
+
|
|
96
|
+
return "other"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def normalize_language_code(lang: Optional[str]) -> str:
|
|
100
|
+
"""Normalizes various user language inputs (e.g. 'eng', 'kor', 'hindi', 'russian') to ISO 2-letter codes."""
|
|
101
|
+
if not lang:
|
|
102
|
+
return "auto"
|
|
103
|
+
cleaned = lang.strip().lower()
|
|
104
|
+
mapping = {
|
|
105
|
+
"ko": "ko", "kor": "ko", "korean": "ko", "한국어": "ko", "한글": "ko",
|
|
106
|
+
"en": "en", "eng": "en", "english": "en", "영어": "en",
|
|
107
|
+
"ja": "ja", "jpn": "ja", "japanese": "ja", "일본어": "ja", "일어": "ja",
|
|
108
|
+
"zh": "zh", "cmn": "zh", "chinese": "zh", "중국어": "zh", "중문": "zh",
|
|
109
|
+
"hi": "hi", "hin": "hi", "hindi": "hi", "힌디어": "hi",
|
|
110
|
+
"ru": "ru", "rus": "ru", "russian": "ru", "러시아어": "ru", "노어": "ru",
|
|
111
|
+
"es": "es", "spa": "es", "spanish": "es", "스페인어": "es",
|
|
112
|
+
"fr": "fr", "fra": "fr", "fre": "fr", "french": "fr", "프랑스어": "fr",
|
|
113
|
+
"de": "de", "deu": "de", "ger": "de", "german": "de", "독일어": "de",
|
|
114
|
+
"ar": "ar", "ara": "ar", "arabic": "ar", "아랍어": "ar",
|
|
115
|
+
"auto": "auto", "all": "auto", "multi": "auto", "multilingual": "auto",
|
|
116
|
+
}
|
|
117
|
+
return mapping.get(cleaned, cleaned)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class MultilingualTokenizer:
|
|
121
|
+
"""
|
|
122
|
+
Intelligent context-aware Code-Switching Tokenizer.
|
|
123
|
+
Partitions input text into language chunks and calculates optimal acoustic pauses
|
|
124
|
+
for seamless, artifact-free multi-speaker concatenation.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
DEFAULT_PAUSE_SENTENCE = 0.250 # . ? ! (Sentence boundary breathing pause)
|
|
128
|
+
DEFAULT_PAUSE_CLAUSE = 0.150 # , ; : (Clause boundary short breath)
|
|
129
|
+
DEFAULT_PAUSE_AFFIX = 0.020 # Direct word+particle affix (e.g. 'parrot'+'이라고')
|
|
130
|
+
DEFAULT_PAUSE_WORD = 0.060 # Normal inter-word space
|
|
131
|
+
|
|
132
|
+
def __init__(self, default_language: str = "ko"):
|
|
133
|
+
self.default_language = default_language.lower()
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def detect_languages(text: str) -> Set[str]:
|
|
137
|
+
"""Returns the set of natural languages detected in the text, ignoring expressive bracket markup."""
|
|
138
|
+
cleaned = re.sub(r"\[[a-zA-Z가-힣_]+\]", "", text)
|
|
139
|
+
detected = set()
|
|
140
|
+
for ch in cleaned:
|
|
141
|
+
script = ScriptRegistry.classify_code_point(ord(ch))
|
|
142
|
+
if script in ("ko", "en", "ja", "hi", "ru", "ar") or script in ScriptRegistry._CUSTOM_MATCHERS:
|
|
143
|
+
detected.add(script)
|
|
144
|
+
elif script == "cjk_ideograph":
|
|
145
|
+
detected.add("zh")
|
|
146
|
+
return detected
|
|
147
|
+
|
|
148
|
+
def tokenize(self, text: str, force_language: Optional[str] = None) -> List[LanguageChunk]:
|
|
149
|
+
"""
|
|
150
|
+
Dynamically partitions mixed-script text into language chunks with
|
|
151
|
+
contextual silence duration.
|
|
152
|
+
If force_language is specified ('ko', 'en', etc.), the entire text is
|
|
153
|
+
routed to that language without multi-script splitting.
|
|
154
|
+
"""
|
|
155
|
+
if not text or not text.strip():
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
norm_forced = normalize_language_code(force_language)
|
|
159
|
+
if norm_forced != "auto":
|
|
160
|
+
clean_str = text.strip()
|
|
161
|
+
if any(clean_str.endswith(p) for p in (".", "?", "!")):
|
|
162
|
+
pause = self.DEFAULT_PAUSE_SENTENCE
|
|
163
|
+
elif any(clean_str.endswith(p) for p in (",", ";", ":")):
|
|
164
|
+
pause = self.DEFAULT_PAUSE_CLAUSE
|
|
165
|
+
else:
|
|
166
|
+
pause = self.DEFAULT_PAUSE_WORD
|
|
167
|
+
|
|
168
|
+
return [LanguageChunk(text=clean_str, language=norm_forced, pause_after=pause, is_affix=False)]
|
|
169
|
+
|
|
170
|
+
# Contextual disambiguation for CJK Ideographs (Kanji vs Hanzi vs Hanja)
|
|
171
|
+
has_kana = any(ScriptRegistry.classify_code_point(ord(c)) == "ja" for c in text)
|
|
172
|
+
has_hangul = any(ScriptRegistry.classify_code_point(ord(c)) == "ko" for c in text)
|
|
173
|
+
|
|
174
|
+
tokens: List[LanguageChunk] = []
|
|
175
|
+
current_lang: Optional[str] = None
|
|
176
|
+
buffer_chars: List[str] = []
|
|
177
|
+
has_trailing_space: bool = False
|
|
178
|
+
|
|
179
|
+
def flush_chunk(is_affix: bool = False):
|
|
180
|
+
nonlocal buffer_chars, current_lang
|
|
181
|
+
raw_chunk = "".join(buffer_chars).strip()
|
|
182
|
+
if raw_chunk and current_lang:
|
|
183
|
+
if any(raw_chunk.endswith(p) for p in (".", "?", "!")):
|
|
184
|
+
pause = self.DEFAULT_PAUSE_SENTENCE
|
|
185
|
+
elif any(raw_chunk.endswith(p) for p in (",", ";", ":")):
|
|
186
|
+
pause = self.DEFAULT_PAUSE_CLAUSE
|
|
187
|
+
elif is_affix:
|
|
188
|
+
pause = self.DEFAULT_PAUSE_AFFIX
|
|
189
|
+
else:
|
|
190
|
+
pause = self.DEFAULT_PAUSE_WORD
|
|
191
|
+
|
|
192
|
+
tokens.append(LanguageChunk(
|
|
193
|
+
text=raw_chunk,
|
|
194
|
+
language=current_lang,
|
|
195
|
+
pause_after=pause,
|
|
196
|
+
is_affix=is_affix
|
|
197
|
+
))
|
|
198
|
+
buffer_chars = []
|
|
199
|
+
|
|
200
|
+
for ch in text:
|
|
201
|
+
script = ScriptRegistry.classify_code_point(ord(ch))
|
|
202
|
+
|
|
203
|
+
# Resolve effective language for ideographs
|
|
204
|
+
resolved_lang: Optional[str] = None
|
|
205
|
+
if script in ("ko", "en", "ja") or script in ScriptRegistry._CUSTOM_MATCHERS:
|
|
206
|
+
resolved_lang = script
|
|
207
|
+
elif script == "cjk_ideograph":
|
|
208
|
+
if has_kana and not has_hangul:
|
|
209
|
+
resolved_lang = "ja" # Japanese Kanji
|
|
210
|
+
elif has_hangul and not has_kana:
|
|
211
|
+
resolved_lang = "ko" # Korean Hanja context
|
|
212
|
+
else:
|
|
213
|
+
resolved_lang = "zh" # Chinese Hanzi
|
|
214
|
+
|
|
215
|
+
if resolved_lang is not None:
|
|
216
|
+
# Check sentence boundary even within same language (e.g., '알지? 뛰어난')
|
|
217
|
+
ends_with_sentence_punct = any("".join(buffer_chars).strip().endswith(p) for p in (".", "?", "!"))
|
|
218
|
+
|
|
219
|
+
if current_lang is None:
|
|
220
|
+
current_lang = resolved_lang
|
|
221
|
+
buffer_chars.append(ch)
|
|
222
|
+
has_trailing_space = False
|
|
223
|
+
elif current_lang != resolved_lang:
|
|
224
|
+
# Language switch boundary!
|
|
225
|
+
is_affix = not has_trailing_space
|
|
226
|
+
flush_chunk(is_affix=is_affix)
|
|
227
|
+
current_lang = resolved_lang
|
|
228
|
+
buffer_chars.append(ch)
|
|
229
|
+
has_trailing_space = False
|
|
230
|
+
elif ends_with_sentence_punct and has_trailing_space:
|
|
231
|
+
# Sentence boundary split within same language!
|
|
232
|
+
flush_chunk(is_affix=False)
|
|
233
|
+
current_lang = resolved_lang
|
|
234
|
+
buffer_chars.append(ch)
|
|
235
|
+
has_trailing_space = False
|
|
236
|
+
else:
|
|
237
|
+
buffer_chars.append(ch)
|
|
238
|
+
has_trailing_space = False
|
|
239
|
+
|
|
240
|
+
elif script == "space":
|
|
241
|
+
if buffer_chars:
|
|
242
|
+
buffer_chars.append(ch)
|
|
243
|
+
has_trailing_space = True
|
|
244
|
+
else:
|
|
245
|
+
# Punctuation / digits / symbols belong to the active language chunk
|
|
246
|
+
if buffer_chars:
|
|
247
|
+
buffer_chars.append(ch)
|
|
248
|
+
|
|
249
|
+
# Flush final chunk
|
|
250
|
+
if buffer_chars and current_lang:
|
|
251
|
+
flush_chunk(is_affix=False)
|
|
252
|
+
|
|
253
|
+
return tokens
|
package/termux_tts/tokenizer.py
CHANGED
|
@@ -125,9 +125,11 @@ def normalize_numbers_english(text: str) -> str:
|
|
|
125
125
|
class PhoneticTokenizer:
|
|
126
126
|
def __init__(self, language: str = "ko"):
|
|
127
127
|
self.language = language.lower()
|
|
128
|
+
if self.language in ("auto", "multilingual"):
|
|
129
|
+
self.language = "ko"
|
|
128
130
|
self.vocab = VOCAB
|
|
129
131
|
if self.language not in ["ko", "korean", "en", "english"]:
|
|
130
|
-
raise TTSLanguageNotSupportedError(f"Language '{language}' is not supported. Supported: ['ko', 'en']")
|
|
132
|
+
raise TTSLanguageNotSupportedError(f"Language '{language}' is not supported. Supported: ['ko', 'en', 'auto', 'multilingual']")
|
|
131
133
|
|
|
132
134
|
def normalize_text(self, text: str) -> str:
|
|
133
135
|
if not text or not text.strip():
|