termux-tts 1.5.0 → 1.5.2

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.
@@ -1,151 +0,0 @@
1
- """
2
- termux_tts.control.models
3
- ModelRegistry 연동 — TTS 모델 + Voice 분리 관리.
4
-
5
- TTS 특이사항:
6
- - model_id: "dsp-ko", "piper-ko", "fastspeech-ko" 등 엔진/언어 조합
7
- - voice_id: "ko-speaker-01", "en-speaker-01" 등 화자 ID (모델에 종속)
8
- - 내장 엔진(dsp/native)은 파일 없이도 항상 사용 가능 → missing 상태로 표기 금지
9
- - ONNX 모델은 파일 존재만으로 installed 처리 금지 → Registry 기반
10
- """
11
- from __future__ import annotations
12
-
13
- import os
14
- import time
15
- from pathlib import Path
16
- from typing import Any
17
-
18
- from ameva_component import ModelRegistry
19
- from ameva_component.manifest import ModelState
20
-
21
- COMPONENT_ID = "termux-tts"
22
-
23
- # ------------------------------------------------------------------
24
- # 내장(Built-in) 엔진 정의 — 파일 불필요, 항상 사용 가능
25
- # ------------------------------------------------------------------
26
-
27
- # format: {model_id: {description, engine, voices, backend_key}}
28
- BUILTIN_MODELS: dict[str, dict[str, Any]] = {
29
- "dsp-ko": {
30
- "model_id": "dsp-ko",
31
- "description": "Pure Parametric DSP Formant Synthesizer (Korean, zero-dependency)",
32
- "engine": "dsp",
33
- "format": "builtin",
34
- "language": "ko",
35
- "voices": [
36
- {"voice_id": "ko-default", "description": "Default Korean DSP voice", "language": "ko"},
37
- ],
38
- "state": ModelState.ACTIVE.value, # 내장 엔진은 항상 사용 가능
39
- },
40
- "dsp-en": {
41
- "model_id": "dsp-en",
42
- "description": "Pure Parametric DSP Formant Synthesizer (English, zero-dependency)",
43
- "engine": "dsp",
44
- "format": "builtin",
45
- "language": "en",
46
- "voices": [
47
- {"voice_id": "en-default", "description": "Default English DSP voice", "language": "en"},
48
- ],
49
- "state": ModelState.ACTIVE.value,
50
- },
51
- "native-system": {
52
- "model_id": "native-system",
53
- "description": "Android System Native Voice (Samsung/Google TTS bridge)",
54
- "engine": "native",
55
- "format": "builtin",
56
- "language": "ko,en",
57
- "voices": [
58
- {"voice_id": "system-default", "description": "Android system TTS voice", "language": "ko,en"},
59
- ],
60
- "state": ModelState.ACTIVE.value,
61
- },
62
- }
63
-
64
-
65
- class TTSModelRegistry:
66
- """
67
- ModelRegistry를 감싸는 TTS 전용 레지스트리.
68
-
69
- 내장 엔진(dsp, native)은 파일 없이도 항상 사용 가능하며,
70
- ONNX 모델만 ModelRegistry를 통해 상태를 추적합니다.
71
- """
72
-
73
- def __init__(self, registry_dir: Path | None = None) -> None:
74
- self._reg = ModelRegistry(COMPONENT_ID, registry_dir=registry_dir)
75
-
76
- # ------------------------------------------------------------------
77
- # 전체 목록 조회
78
- # ------------------------------------------------------------------
79
-
80
- def list_all(self) -> list[dict]:
81
- """
82
- 모든 모델 레코드를 반환합니다.
83
- - 내장 엔진: BUILTIN_MODELS에서 직접 반환
84
- - ONNX 모델: ModelRegistry에서 실제 상태 조회
85
- """
86
- result: list[dict] = list(BUILTIN_MODELS.values())
87
- # Registry에서 외부 ONNX 모델 추가
88
- for rec in self._reg.list_all():
89
- result.append(self._enrich_onnx_record(rec))
90
- return result
91
-
92
- def list_voices_for_model(self, model_id: str) -> list[dict]:
93
- """특정 model_id의 voice 목록을 반환합니다."""
94
- if model_id in BUILTIN_MODELS:
95
- return BUILTIN_MODELS[model_id].get("voices", [])
96
- rec = self._reg.get(model_id)
97
- if rec is None:
98
- return []
99
- return rec.get("voices", [])
100
-
101
- def get(self, model_id: str) -> dict | None:
102
- """단일 모델 레코드를 반환합니다. 없으면 None."""
103
- if model_id in BUILTIN_MODELS:
104
- return BUILTIN_MODELS[model_id]
105
- rec = self._reg.get(model_id)
106
- if rec is None:
107
- return None
108
- return self._enrich_onnx_record(rec)
109
-
110
- def get_state(self, model_id: str) -> ModelState | None:
111
- """모델 상태를 반환합니다. 없으면 None."""
112
- if model_id in BUILTIN_MODELS:
113
- return ModelState.ACTIVE
114
- rec = self._reg.get(model_id)
115
- if rec is None:
116
- return None
117
- try:
118
- return ModelState(rec.get("state", "missing"))
119
- except ValueError:
120
- return None
121
-
122
- # ------------------------------------------------------------------
123
- # 상태 변경
124
- # ------------------------------------------------------------------
125
-
126
- def set_onnx_active(self, model_id: str) -> None:
127
- """ONNX 모델을 active 상태로 변경합니다."""
128
- self._reg.set_state(model_id, ModelState.ACTIVE)
129
-
130
- def set_onnx_inactive(self, model_id: str) -> None:
131
- """ONNX 모델을 inactive 상태로 변경합니다."""
132
- self._reg.set_state(model_id, ModelState.INACTIVE)
133
-
134
- def record_onnx_install(self, model_id: str, manifest, verified_at: float) -> None:
135
- """ONNX 모델 설치 완료를 기록합니다."""
136
- self._reg.record_install(model_id, manifest, verified_at)
137
-
138
- def set_onnx_state(self, model_id: str, state: ModelState, *, last_error: str | None = None) -> None:
139
- """ONNX 모델 상태를 직접 갱신합니다."""
140
- self._reg.set_state(model_id, state, last_error=last_error)
141
-
142
- # ------------------------------------------------------------------
143
- # 내부 헬퍼
144
- # ------------------------------------------------------------------
145
-
146
- def _enrich_onnx_record(self, rec: dict) -> dict:
147
- """ONNX 레코드에 engine/voices 필드가 없으면 기본값을 추가합니다."""
148
- enriched = dict(rec)
149
- enriched.setdefault("engine", "onnx")
150
- enriched.setdefault("voices", [])
151
- return enriched
@@ -1,13 +0,0 @@
1
- """
2
- termux_tts.control.status — TTS 상태 파일 Writer + Heartbeat
3
- """
4
- from __future__ import annotations
5
- from typing import Any
6
- from ameva_component.heartbeat import HeartbeatWriter
7
-
8
-
9
- class TTSStatusWriter(HeartbeatWriter):
10
- """TTSControl 상태를 10초마다 상태 파일에 원자적으로 기록합니다."""
11
-
12
- def __init__(self, control: Any) -> None:
13
- super().__init__(control, name="tts")
@@ -1,340 +0,0 @@
1
- """
2
- Unified 4-Tier Multi-Backend TTS Gateway for termux-tts:
3
- - Tier 1: Pure Parametric DSP Formant Synthesizer Engine (ParametricDSPEngine - 0MB Zero-Dependency)
4
- - Tier 2: Android System Native Samsung/Google Voice Engine Bridge (NativeAndroidEngine - 0MB OS IPC)
5
- - Tier 3: Authentic C++ Isolated Sherpa-ONNX VITS Neural Vocoder (SherpaNeuralEngine - Deep Learning)
6
- - Tier 4: Pure On-Device Expressive Emotional Synthesizer (ExpressiveEngine - Conversational Tags)
7
-
8
- Strict Zero-Silent-Fallback Protocol:
9
- - If caller explicitly requests 'neural' and assets are missing -> Raise TTSModelLoadError (FAIL-FAST)
10
- - If caller explicitly requests 'native' and termux-api is missing -> Raise TTSInferenceError (FAIL-FAST)
11
- - Never swallow exceptions or mask failures with robotic buzzers without explicit instruction.
12
- """
13
- from __future__ import annotations
14
-
15
- import logging
16
- import os
17
- from typing import Optional, Union, Dict, Any
18
-
19
- from .exceptions import TTSInferenceError, TTSModelLoadError, VulkanInitializationError
20
- from .engine_native import NativeAndroidEngine, NativeResult
21
- from .engine_sherpa import SherpaNeuralEngine, SherpaResult
22
- from .engine_vulkan import VulkanNeuralEngine, VulkanResult
23
- from .engine_expressive import ExpressiveEngine, ExpressiveResult
24
- from .engine_multilingual import MultilingualNeuralEngine, MultilingualResult
25
- from .script_classifier import MultilingualTokenizer
26
- from .hardware import (
27
- resolve_device_backend,
28
- bind_tts_hardware,
29
- _resolve_ameva_runtime,
30
- ERROR_AMEVA_TTS_E001,
31
- )
32
-
33
- logger = logging.getLogger("termux_tts.engine")
34
-
35
-
36
- QUALITY_PRESETS = {
37
- "fast": {"sample_rate": 16000, "description": "16kHz Low Latency"},
38
- "balanced": {"sample_rate": 22050, "description": "22.05kHz Standard Audio"},
39
- "expressive": {"sample_rate": 24000, "description": "24kHz Expressive High Fidelity"},
40
- "ultra": {"sample_rate": 44100, "description": "44.1kHz Studio Master"},
41
- }
42
-
43
-
44
- class TTSEngine:
45
- """Production 4-Tier Multi-Backend Gateway supporting Synth, Native, Neural, and Expressive engines."""
46
-
47
- def __init__(
48
- self,
49
- model_path: Optional[str] = None,
50
- language: str = "ko",
51
- preset: str = "balanced",
52
- device: str = "auto",
53
- threads: int = 4,
54
- sample_rate: Optional[int] = None,
55
- engine_type: str = "auto",
56
- model_tier: Optional[str] = None,
57
- ):
58
- self.language = language.lower()
59
- self.preset = preset.lower()
60
- self.requested_device = device.lower()
61
- self.requested_engine_type = engine_type.lower()
62
- self.model_path = model_path
63
- self.threads = threads
64
- self.sample_rate = sample_rate
65
- self.model_tier = model_tier
66
- self._is_closed = False
67
-
68
- # 1. Resolve effective device and engine_type via safe hardware gateway
69
- self.device, effective_engine = resolve_device_backend(
70
- self.requested_device, self.requested_engine_type
71
- )
72
- if self.requested_engine_type == "auto" and effective_engine != "auto":
73
- self.requested_engine_type = effective_engine
74
-
75
- self.backend = "auto"
76
-
77
- # 2. Attempt ameva-runtime hardware binding if available
78
- self._binding_plan = self._bind_hardware()
79
-
80
- self.native_engine = NativeAndroidEngine(language=language)
81
- self._multilingual_engine: Optional[MultilingualNeuralEngine] = None
82
- self.synth_engine = self._resolve_synth_engine()
83
-
84
- def _bind_hardware(self):
85
- return bind_tts_hardware(self, self.requested_device)
86
-
87
- def _resolve_synth_engine(self):
88
- t = self.requested_engine_type
89
- from .script_classifier import normalize_language_code
90
- norm_lang = normalize_language_code(self.language)
91
-
92
- # Extended Languages (hi, ru, ja, zh, es, fr, de, ar) route to MultilingualNeuralEngine
93
- if norm_lang in ("hi", "ru", "ja", "zh", "es", "fr", "de", "ar"):
94
- return self._get_multilingual_engine()
95
-
96
- # Explicit Vulkan GPU MeloTTS Tier (Plan 1 NCNN Sliced / Plan 2 MNN Vulkan)
97
- if t in ("melo", "melo_vulkan", "melo_ncnn", "melo_mnn") and (self.requested_device in ("vulkan", "gpu") or self.device == "vulkan"):
98
- return VulkanNeuralEngine(
99
- model_path=self.model_path,
100
- language=self.language,
101
- device=self.requested_device,
102
- threads=self.threads,
103
- sample_rate=self.sample_rate or 44100,
104
- model_tier=self.model_tier,
105
- model_type=t,
106
- )
107
-
108
- # Explicit Vulkan GPU Tier (Vulkan NCNN engine targets English Lessac)
109
- if (t in ("vulkan", "gpu", "ncnn") or (self.requested_device in ("vulkan", "gpu") and t in ("neural", "vits", "auto"))) and norm_lang in ("en", "auto"):
110
- try:
111
- return VulkanNeuralEngine(
112
- model_path=self.model_path,
113
- language=self.language,
114
- device=self.requested_device,
115
- threads=self.threads,
116
- sample_rate=self.sample_rate or 22050,
117
- model_tier=self.model_tier,
118
- model_type="vits",
119
- )
120
- except (VulkanInitializationError, TTSModelLoadError) as err:
121
- if t in ("vulkan", "gpu", "ncnn") or self.requested_device in ("vulkan", "gpu"):
122
- raise
123
- logger.debug("Vulkan neural engine not ready, trying fallback: %s", err)
124
-
125
- # Explicit Tier 3: Neural (Sherpa-ONNX CPU)
126
- if t in ("neural", "onnx", "sherpa", "vits"):
127
- return SherpaNeuralEngine(
128
- model_path=self.model_path,
129
- language=self.language,
130
- device=self.requested_device,
131
- threads=self.threads,
132
- sample_rate=self.sample_rate or 22050,
133
- model_type="vits",
134
- )
135
-
136
- # BigTech 3rd-Party Neural Speech Engines (StyleTTS2/Kokoro, MeloTTS, Supertonic)
137
- elif t in ("kokoro", "melo", "supertonic"):
138
- return SherpaNeuralEngine(
139
- model_path=self.model_path,
140
- language=self.language,
141
- device=self.requested_device,
142
- threads=self.threads,
143
- sample_rate=self.sample_rate or 22050,
144
- model_type=t,
145
- )
146
-
147
- # Explicit Tier 4: Expressive (Fail-Fast)
148
- elif t in ("expressive", "chat", "conversational"):
149
- return ExpressiveEngine(
150
- model_path=self.model_path,
151
- language=self.language,
152
- device=self.requested_device,
153
- threads=self.threads,
154
- sample_rate=self.sample_rate or 22050,
155
- )
156
-
157
- # Explicit Multilingual / Code-Switching Tier
158
- elif t in ("multilingual", "codeswitch", "hybrid"):
159
- return self._get_multilingual_engine()
160
-
161
- # Explicit Tier 2: Native
162
- elif t == "native":
163
- return self.native_engine
164
-
165
- # Auto Mode (Zero-Silent-Fallback)
166
- elif t == "auto":
167
- # 1. Check if SherpaNeuralEngine assets exist
168
- try:
169
- return SherpaNeuralEngine(
170
- model_path=self.model_path,
171
- language=self.language,
172
- device=self.requested_device,
173
- threads=self.threads,
174
- sample_rate=self.sample_rate or 22050,
175
- )
176
- except TTSModelLoadError as err:
177
- logger.info("Auto tier: Neural model assets not ready (%s). Probing native system voice...", err)
178
-
179
- # 2. Check if NativeAndroidEngine is available
180
- if self.native_engine.binary:
181
- return self.native_engine
182
-
183
- # 3. Fail-Fast: No silent robotic fallback
184
- raise TTSModelLoadError(
185
- "[FAIL-FAST] No TTS voice engine available. Neural speech model is not installed, "
186
- "and native Android TTS engine is unavailable.\n"
187
- " To install official neural models automatically, run:\n"
188
- " termux-tts install\n"
189
- )
190
- else:
191
- raise TTSInferenceError(
192
- f"[FAIL-FAST] Unknown engine_type '{self.requested_engine_type}'. "
193
- f"Available tiers: ['auto', 'native', 'neural', 'expressive', 'multilingual', 'kokoro', 'melo', 'supertonic']"
194
- )
195
-
196
- @property
197
- def active_backend(self) -> str:
198
- return getattr(self.synth_engine, "backend", "NATIVE_SYSTEM")
199
-
200
- @property
201
- def model_name(self) -> str:
202
- return getattr(self.synth_engine, "model_name", "native-system-voice")
203
-
204
- @property
205
- def binary(self) -> Optional[str]:
206
- return getattr(self.synth_engine, "binary", getattr(self.native_engine, "binary", None))
207
-
208
- def _get_multilingual_engine(self) -> MultilingualNeuralEngine:
209
- if self._multilingual_engine is None:
210
- self._multilingual_engine = MultilingualNeuralEngine(
211
- threads=self.threads,
212
- device=self.device,
213
- sample_rate=self.sample_rate or 22050,
214
- )
215
- return self._multilingual_engine
216
-
217
- def speak(self, text: str, stream: Optional[str] = None) -> NativeResult:
218
- """Speak text directly through physical Android speaker (Native Engine)."""
219
- if self._is_closed:
220
- raise TTSInferenceError("Cannot speak: Engine session is closed.")
221
- return self.native_engine.speak(text, stream=stream)
222
-
223
- def synthesize(
224
- self,
225
- text: str,
226
- output: Optional[str] = None,
227
- speed: float = 1.0,
228
- preset: Optional[str] = None,
229
- language: Optional[str] = None,
230
- mode: str = "unified",
231
- ) -> Union[SherpaResult, ExpressiveResult, NativeResult, MultilingualResult]:
232
- """Synthesize text into speech audio buffer / WAV file with Zero-Config intelligent routing."""
233
- if self._is_closed:
234
- raise TTSInferenceError("Cannot synthesize: Engine session is closed.")
235
-
236
- clean_text = text.strip() if text else ""
237
- if not clean_text:
238
- raise TTSInferenceError("Cannot synthesize empty text.")
239
-
240
- from .script_classifier import normalize_language_code
241
- target_lang = normalize_language_code(language or self.language)
242
-
243
- # 1. If user explicitly pinned to OS Native voice, speak directly
244
- if self.requested_engine_type == "native":
245
- return self.native_engine.speak(clean_text)
246
-
247
- # 2. Multilingual auto-detection:
248
- # Route to MultilingualNeuralEngine if multiple languages are present in text
249
- # or if caller specifically requested multilingual / hybrid engine.
250
- detected_langs = MultilingualTokenizer.detect_languages(clean_text)
251
- is_mixed_text = len(detected_langs) > 1
252
-
253
- should_route_multilingual = (
254
- (self.requested_engine_type in ("multilingual", "codeswitch", "hybrid")) or
255
- (self.requested_engine_type == "auto" and (is_mixed_text or self._multilingual_engine is not None)) or
256
- (is_mixed_text and self.requested_engine_type != "native") or
257
- (target_lang in ("hi", "ru", "ja", "zh", "es", "fr", "de", "ar") and self.requested_engine_type != "native")
258
- )
259
-
260
- if should_route_multilingual:
261
- multi_engine = self._get_multilingual_engine()
262
- kwargs = {}
263
- if target_lang != "auto":
264
- kwargs["language"] = target_lang
265
- return multi_engine.synthesize(
266
- clean_text,
267
- output=output,
268
- speed=speed,
269
- mode=mode,
270
- **kwargs
271
- )
272
-
273
- # 4. Standard single-language engine
274
- if hasattr(self.synth_engine, "synthesize"):
275
- return self.synth_engine.synthesize(clean_text, output=output, speed=speed, preset=preset)
276
- elif hasattr(self.synth_engine, "speak"):
277
- return self.synth_engine.speak(clean_text)
278
- raise TTSInferenceError(f"Selected engine '{type(self.synth_engine).__name__}' does not support synthesize.")
279
-
280
- def close(self) -> None:
281
- self._is_closed = True
282
- self.native_engine.close()
283
- if hasattr(self.synth_engine, "close"):
284
- self.synth_engine.close()
285
- if self._multilingual_engine is not None:
286
- self._multilingual_engine.close()
287
-
288
- def __enter__(self):
289
- return self
290
-
291
- def __exit__(self, exc_type, exc_val, exc_tb):
292
- self.close()
293
-
294
-
295
- def load(
296
- model: Optional[str] = None,
297
- language: str = "auto",
298
- preset: str = "balanced",
299
- device: str = "auto",
300
- threads: int = 4,
301
- sample_rate: Optional[int] = None,
302
- engine: str = "auto",
303
- tier: Optional[str] = None,
304
- ) -> TTSEngine:
305
- return TTSEngine(
306
- model_path=model,
307
- language=language,
308
- preset=preset,
309
- device=device,
310
- threads=threads,
311
- sample_rate=sample_rate,
312
- engine_type=engine,
313
- model_tier=tier,
314
- )
315
-
316
-
317
- def doctor() -> Dict[str, Any]:
318
- ameva_mod = _resolve_ameva_runtime()
319
- if ameva_mod is not None:
320
- try:
321
- from ameva_runtime.adapters import TtsAdapter
322
- adapter = TtsAdapter()
323
- rep = adapter.resolve_diagnostic_report()
324
- return {
325
- "doctor_report": rep,
326
- "overall_success": getattr(rep, "overall_success", False),
327
- "passed_stages": getattr(rep, "passed_stages", 0),
328
- "recommended_backend": getattr(rep, "recommended_backend", "cpu_neon"),
329
- "status": "DIAGNOSED_VIA_AMEVA",
330
- }
331
- except Exception as e:
332
- logger.debug("AMEVA Doctor invocation exception: %s", e)
333
-
334
- return {
335
- "doctor_report": None,
336
- "overall_success": False,
337
- "passed_stages": 0,
338
- "recommended_backend": "cpu_neon",
339
- "status": "NO_AMEVA_RUNTIME",
340
- }