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.
@@ -9,10 +9,34 @@ import urllib.request
9
9
  import shutil
10
10
  from pathlib import Path
11
11
 
12
- VULKAN_BINARY_RELEASE_URL = (
13
- "https://github.com/uno-km/termux-sherpa-ncnn/releases/download/"
14
- "v1.0.0-vulkan/sherpa-ncnn-offline-tts-vulkan-arm64.tar.gz"
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/1.3.0"})
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
- download_with_progress(VULKAN_BINARY_RELEASE_URL, tar_path, "ARM64 Vulkan Binary (3.9MB)")
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 run_installation(tier: str = "high", force: bool = False, play: bool = True):
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 VULKAN GPU AUTOMATED PROVISIONER (1-CLICK SETUP)")
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
@@ -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():