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.
- package/bin/cli.js +40 -22
- package/package.json +9 -1
- package/.github/workflows/release.yml +0 -98
- package/CHANGELOG.md +0 -93
- package/doc.config.yaml +0 -595
- package/docs/benchmarks.md +0 -12
- package/docs/guide.md +0 -552
- package/docs/tts_guide.md +0 -552
- package/install.sh +0 -53
- package/pyproject.toml +0 -81
- package/setup.py +0 -38
- package/termux_tts/__init__.py +0 -70
- package/termux_tts/adapter.py +0 -202
- package/termux_tts/audio.py +0 -101
- package/termux_tts/cli.py +0 -156
- package/termux_tts/control/__init__.py +0 -4
- package/termux_tts/control/component.py +0 -398
- package/termux_tts/control/errors.py +0 -52
- package/termux_tts/control/instances.py +0 -137
- package/termux_tts/control/models.py +0 -151
- package/termux_tts/control/status.py +0 -13
- package/termux_tts/engine.py +0 -340
- package/termux_tts/engine_expressive.py +0 -216
- package/termux_tts/engine_multilingual.py +0 -409
- package/termux_tts/engine_native.py +0 -104
- package/termux_tts/engine_onnx.py +0 -8
- package/termux_tts/engine_sherpa.py +0 -388
- package/termux_tts/engine_sherpa_capi.py +0 -508
- package/termux_tts/engine_vulkan.py +0 -395
- package/termux_tts/exceptions.py +0 -28
- package/termux_tts/g2p_korean.py +0 -242
- package/termux_tts/hardware.py +0 -166
- package/termux_tts/installer.py +0 -381
- package/termux_tts/script_classifier.py +0 -253
- package/termux_tts/tokenizer.py +0 -186
- package/termux_tts/tokenizer_melo.py +0 -159
- package/termux_tts/transliteration.py +0 -250
|
@@ -1,398 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
termux_tts.control.component
|
|
3
|
-
AMEVA Component Protocol v1 — TTSControl
|
|
4
|
-
|
|
5
|
-
기존 TTSEngine.doctor() Adapter 연결.
|
|
6
|
-
Model/Voice 분리 추적 (신규 — 기존 코드에 없음).
|
|
7
|
-
doctor_lite: 상태파일 + PID만 (12단계 Vulkan Doctor는 doctor_full에만).
|
|
8
|
-
"""
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
import os
|
|
12
|
-
import time
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
from typing import Any
|
|
15
|
-
|
|
16
|
-
from ameva_component import (
|
|
17
|
-
ActivationLock, ComponentInfo, ComponentStateFile,
|
|
18
|
-
ControlMode, InstanceRegistry, InstanceState, InstanceStatus,
|
|
19
|
-
ModelRegistry, ModelState, ModelNotFound, ModelLoadFailed,
|
|
20
|
-
OperationNotSupported, now_timestamps, log_stderr, PROTOCOL_COMPONENT,
|
|
21
|
-
)
|
|
22
|
-
from ameva_component.control import ComponentControl
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class TTSControl(ComponentControl):
|
|
26
|
-
"""
|
|
27
|
-
termux-tts ComponentControl.
|
|
28
|
-
Model/Voice 분리: model_id=piper-ko / voice_id=ko-speaker-01
|
|
29
|
-
기존 engine.py TTSEngine은 Adapter로 연결합니다.
|
|
30
|
-
"""
|
|
31
|
-
|
|
32
|
-
COMPONENT_ID = "termux-tts"
|
|
33
|
-
COMPONENT_TYPE = "tts"
|
|
34
|
-
CAPABILITIES = ("audio.synthesize", "voice.list")
|
|
35
|
-
|
|
36
|
-
DEFAULT_MODELS_DIR = Path.home() / ".cache" / "termux-tts" / "models"
|
|
37
|
-
DEFAULT_PID_FILE = Path.home() / ".local" / "run" / "termux-tts.pid"
|
|
38
|
-
|
|
39
|
-
def __init__(self, models_dir: Path | None = None) -> None:
|
|
40
|
-
self._models_dir = models_dir or self.DEFAULT_MODELS_DIR
|
|
41
|
-
self._state_file = ComponentStateFile(self.COMPONENT_ID)
|
|
42
|
-
self._model_reg = ModelRegistry(self.COMPONENT_ID)
|
|
43
|
-
self._inst_reg = InstanceRegistry(self.COMPONENT_ID)
|
|
44
|
-
self._act_lock = ActivationLock()
|
|
45
|
-
# Phase 4: Heartbeat Writer
|
|
46
|
-
from termux_tts.control.status import TTSStatusWriter
|
|
47
|
-
self._heartbeat = TTSStatusWriter(self)
|
|
48
|
-
|
|
49
|
-
def _get_version(self) -> str:
|
|
50
|
-
try:
|
|
51
|
-
from termux_tts import __version__; return __version__
|
|
52
|
-
except Exception: return "1.1.2"
|
|
53
|
-
|
|
54
|
-
def component_info(self) -> dict:
|
|
55
|
-
info = ComponentInfo(
|
|
56
|
-
protocol=PROTOCOL_COMPONENT, component_id=self.COMPONENT_ID,
|
|
57
|
-
component_type=self.COMPONENT_TYPE, version=self._get_version(),
|
|
58
|
-
capabilities=self.CAPABILITIES,
|
|
59
|
-
)
|
|
60
|
-
info.validate()
|
|
61
|
-
return info.to_dict()
|
|
62
|
-
|
|
63
|
-
def doctor_lite(self) -> dict:
|
|
64
|
-
"""
|
|
65
|
-
경량 진단.
|
|
66
|
-
12단계 Vulkan Doctor 금지 — doctor_full()에서만 호출.
|
|
67
|
-
"""
|
|
68
|
-
ts = now_timestamps()
|
|
69
|
-
state_data = self._state_file.read()
|
|
70
|
-
stale = self._state_file.is_stale(threshold_ms=30_000)
|
|
71
|
-
pid_info = self._check_pid()
|
|
72
|
-
pid = pid_info.get("pid")
|
|
73
|
-
pid_alive = pid_info.get("alive")
|
|
74
|
-
instances = self._inst_reg.list_all()
|
|
75
|
-
hot = [i for i in instances if i.state == InstanceState.HOT]
|
|
76
|
-
|
|
77
|
-
# DSP 백엔드는 항상 가용 (Zero Dependency)
|
|
78
|
-
dsp_available = True
|
|
79
|
-
onnx_available = False
|
|
80
|
-
try:
|
|
81
|
-
from termux_tts.engine_onnx import ONNXNeuralEngine
|
|
82
|
-
onnx_available = True
|
|
83
|
-
except ImportError as _imp_err:
|
|
84
|
-
import logging
|
|
85
|
-
logging.getLogger(__name__).debug("[tts] ONNXNeuralEngine not importable: %s", _imp_err)
|
|
86
|
-
|
|
87
|
-
ready = dsp_available # DSP는 Zero Dependency이므로 항상 최소 가용
|
|
88
|
-
degraded = stale or (pid_alive is not True)
|
|
89
|
-
|
|
90
|
-
proc_dict: dict[str, Any] = {
|
|
91
|
-
"running": pid_alive,
|
|
92
|
-
"pid": pid,
|
|
93
|
-
"verified": pid_info.get("verified", False),
|
|
94
|
-
}
|
|
95
|
-
if "inspection_error" in pid_info:
|
|
96
|
-
proc_dict["inspection_error"] = pid_info["inspection_error"]
|
|
97
|
-
if "reason" in pid_info:
|
|
98
|
-
proc_dict["reason"] = pid_info["reason"]
|
|
99
|
-
|
|
100
|
-
return {
|
|
101
|
-
"protocol": "ameva-component-status/1",
|
|
102
|
-
"component_id": self.COMPONENT_ID,
|
|
103
|
-
"component_type": self.COMPONENT_TYPE,
|
|
104
|
-
"version": self._get_version(),
|
|
105
|
-
"ready": ready,
|
|
106
|
-
"degraded": degraded,
|
|
107
|
-
**ts,
|
|
108
|
-
"process": proc_dict,
|
|
109
|
-
"capabilities": list(self.CAPABILITIES),
|
|
110
|
-
"active_models": [i.model_id for i in hot],
|
|
111
|
-
"backends": {
|
|
112
|
-
"dsp": dsp_available,
|
|
113
|
-
"onnx": onnx_available,
|
|
114
|
-
"native": None, # Android 런타임에서만 확인 가능
|
|
115
|
-
},
|
|
116
|
-
"errors": [state_data.get("last_error")] if state_data and state_data.get("last_error") else [],
|
|
117
|
-
"state_file": {
|
|
118
|
-
"path": str(self._state_file.path),
|
|
119
|
-
"stale": stale,
|
|
120
|
-
"updated_at": state_data.get("updated_at") if state_data else None,
|
|
121
|
-
},
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
def _check_pid(self) -> dict[str, Any]:
|
|
125
|
-
"""BLOCKER 1: PID 파일 기반 프로세스 활성 여부 확인.
|
|
126
|
-
PermissionError/OSError 발생 시 alive=None, verified=False, inspection_error 반환."""
|
|
127
|
-
import logging
|
|
128
|
-
_log = logging.getLogger(__name__)
|
|
129
|
-
|
|
130
|
-
if self.DEFAULT_PID_FILE.exists():
|
|
131
|
-
try:
|
|
132
|
-
raw = self.DEFAULT_PID_FILE.read_text().strip()
|
|
133
|
-
pid = int(raw)
|
|
134
|
-
except (ValueError, OSError) as parse_err:
|
|
135
|
-
_log.warning("[tts] PID file parse/read error: %s", parse_err)
|
|
136
|
-
return {
|
|
137
|
-
"pid": None,
|
|
138
|
-
"alive": None,
|
|
139
|
-
"verified": False,
|
|
140
|
-
"inspection_error": {
|
|
141
|
-
"code": "PID_PARSE_ERROR",
|
|
142
|
-
"message": str(parse_err),
|
|
143
|
-
},
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
try:
|
|
147
|
-
os.kill(pid, 0)
|
|
148
|
-
return {"pid": pid, "alive": True, "verified": True}
|
|
149
|
-
except ProcessLookupError:
|
|
150
|
-
return {
|
|
151
|
-
"pid": pid,
|
|
152
|
-
"alive": False,
|
|
153
|
-
"verified": True,
|
|
154
|
-
"reason": "process_lookup_failed",
|
|
155
|
-
}
|
|
156
|
-
except PermissionError as perm_err:
|
|
157
|
-
_log.warning("[tts] PID alive check PermissionError: %s", perm_err)
|
|
158
|
-
return {
|
|
159
|
-
"pid": pid,
|
|
160
|
-
"alive": None,
|
|
161
|
-
"verified": False,
|
|
162
|
-
"inspection_error": {
|
|
163
|
-
"code": "PROCESS_INSPECTION_PERMISSION_DENIED",
|
|
164
|
-
"message": str(perm_err),
|
|
165
|
-
},
|
|
166
|
-
}
|
|
167
|
-
except OSError as os_err:
|
|
168
|
-
_log.warning("[tts] PID alive check OSError: %s", os_err)
|
|
169
|
-
return {
|
|
170
|
-
"pid": pid,
|
|
171
|
-
"alive": None,
|
|
172
|
-
"verified": False,
|
|
173
|
-
"inspection_error": {
|
|
174
|
-
"code": "PROCESS_INSPECTION_OS_ERROR",
|
|
175
|
-
"message": str(os_err),
|
|
176
|
-
},
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
state_data = self._state_file.read()
|
|
180
|
-
if state_data:
|
|
181
|
-
pid = state_data.get("process", {}).get("pid")
|
|
182
|
-
if pid:
|
|
183
|
-
try:
|
|
184
|
-
os.kill(pid, 0)
|
|
185
|
-
return {"pid": pid, "alive": True, "verified": True}
|
|
186
|
-
except ProcessLookupError:
|
|
187
|
-
return {
|
|
188
|
-
"pid": pid,
|
|
189
|
-
"alive": False,
|
|
190
|
-
"verified": True,
|
|
191
|
-
"reason": "process_lookup_failed",
|
|
192
|
-
}
|
|
193
|
-
except PermissionError as perm_err:
|
|
194
|
-
_log.warning("[tts] State-file PID %d PermissionError: %s", pid, perm_err)
|
|
195
|
-
return {
|
|
196
|
-
"pid": pid,
|
|
197
|
-
"alive": None,
|
|
198
|
-
"verified": False,
|
|
199
|
-
"inspection_error": {
|
|
200
|
-
"code": "PROCESS_INSPECTION_PERMISSION_DENIED",
|
|
201
|
-
"message": str(perm_err),
|
|
202
|
-
},
|
|
203
|
-
}
|
|
204
|
-
except OSError as os_err:
|
|
205
|
-
_log.warning("[tts] State-file PID %d OSError: %s", pid, os_err)
|
|
206
|
-
return {
|
|
207
|
-
"pid": pid,
|
|
208
|
-
"alive": None,
|
|
209
|
-
"verified": False,
|
|
210
|
-
"inspection_error": {
|
|
211
|
-
"code": "PROCESS_INSPECTION_OS_ERROR",
|
|
212
|
-
"message": str(os_err),
|
|
213
|
-
},
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
"pid": None,
|
|
218
|
-
"alive": False,
|
|
219
|
-
"verified": True,
|
|
220
|
-
"reason": "pid_file_missing",
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
def doctor_full(self) -> dict:
|
|
224
|
-
"""기존 TTSEngine doctor() 호출 — 12단계 Vulkan Doctor 포함."""
|
|
225
|
-
lite = self.doctor_lite()
|
|
226
|
-
try:
|
|
227
|
-
from termux_tts.engine import doctor
|
|
228
|
-
doc_rep = doctor()
|
|
229
|
-
lite["doctor"] = doc_rep
|
|
230
|
-
lite["vulkan"] = doc_rep.get("doctor_report") or {"status": doc_rep.get("status")}
|
|
231
|
-
except Exception as e:
|
|
232
|
-
lite["vulkan_error"] = str(e)
|
|
233
|
-
lite["doctor_level"] = "full"
|
|
234
|
-
return lite
|
|
235
|
-
|
|
236
|
-
def list_models(self) -> dict:
|
|
237
|
-
"""ModelRegistry 기반 + 설치된 파일 스캔."""
|
|
238
|
-
reg_models = self._model_reg.list_all()
|
|
239
|
-
reg_map = {m["model_id"]: m for m in reg_models}
|
|
240
|
-
|
|
241
|
-
# models_dir 스캔 (파일만 존재 = unverified)
|
|
242
|
-
if self._models_dir.exists():
|
|
243
|
-
for p in self._models_dir.glob("*"):
|
|
244
|
-
if p.is_file():
|
|
245
|
-
mid = p.stem
|
|
246
|
-
if mid not in reg_map:
|
|
247
|
-
reg_map[mid] = {
|
|
248
|
-
"model_id": mid,
|
|
249
|
-
"state": "unverified",
|
|
250
|
-
"format": p.suffix.lstrip("."),
|
|
251
|
-
"note": "File found on disk but not verified by AMEVA registry",
|
|
252
|
-
"verified_at": None,
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return {"models": list(reg_map.values()), "total": len(reg_map),
|
|
256
|
-
"models_dir": str(self._models_dir)}
|
|
257
|
-
|
|
258
|
-
def model_status(self, model_id: str | None = None) -> dict:
|
|
259
|
-
if model_id:
|
|
260
|
-
rec = self._model_reg.get(model_id)
|
|
261
|
-
if rec is None: raise ModelNotFound(model_id)
|
|
262
|
-
return {"model": rec}
|
|
263
|
-
return self.list_models()
|
|
264
|
-
|
|
265
|
-
def install_model(self, request: dict) -> dict:
|
|
266
|
-
from ameva_component import ModelInstaller
|
|
267
|
-
url = request.get("url", ""); filename = request.get("filename", "")
|
|
268
|
-
sha256 = request.get("sha256", "")
|
|
269
|
-
expected_bytes = int(request.get("expected_bytes", 0))
|
|
270
|
-
model_id = request.get("model_id") or Path(filename).stem
|
|
271
|
-
self._models_dir.mkdir(parents=True, exist_ok=True)
|
|
272
|
-
installer = ModelInstaller(self.COMPONENT_ID, self._models_dir, self._model_reg)
|
|
273
|
-
return installer.install(url=url, filename=filename, sha256=sha256,
|
|
274
|
-
expected_bytes=expected_bytes, model_id=model_id)
|
|
275
|
-
|
|
276
|
-
async def activate_model(self, request: dict) -> dict:
|
|
277
|
-
model_id = request.get("model_id", "")
|
|
278
|
-
voice_id = request.get("voice_id") # Model/Voice 분리
|
|
279
|
-
rec = self._model_reg.get(model_id)
|
|
280
|
-
if rec is None: raise ModelNotFound(model_id)
|
|
281
|
-
if ModelState.from_str(rec.get("state", "missing")) not in (ModelState.INSTALLED, ModelState.INACTIVE):
|
|
282
|
-
raise ModelLoadFailed(model_id, f"State is '{rec.get('state')}'")
|
|
283
|
-
with self._act_lock.acquire(timeout=60.0):
|
|
284
|
-
self._model_reg.set_state(model_id, ModelState.ACTIVE)
|
|
285
|
-
self._write_state()
|
|
286
|
-
return {"activated": True, "model_id": model_id, "voice_id": voice_id,
|
|
287
|
-
"rollback": {"attempted": False, "succeeded": False}}
|
|
288
|
-
|
|
289
|
-
async def deactivate_model(self, request: dict) -> dict:
|
|
290
|
-
model_id = request.get("model_id", "")
|
|
291
|
-
self._model_reg.set_state(model_id, ModelState.INACTIVE)
|
|
292
|
-
self._write_state()
|
|
293
|
-
return {"deactivated": True, "model_id": model_id}
|
|
294
|
-
|
|
295
|
-
def list_instances(self) -> dict:
|
|
296
|
-
instances = self._inst_reg.list_all()
|
|
297
|
-
return {"instances": [i.to_dict() for i in instances], "total": len(instances)}
|
|
298
|
-
|
|
299
|
-
async def start_instance(self, request: dict) -> dict:
|
|
300
|
-
backend = request.get("backend", "dsp")
|
|
301
|
-
model_id = request.get("model_id", "dsp-default")
|
|
302
|
-
instance_id = request.get("instance_id") or f"tts-{backend}-{int(time.time())}"
|
|
303
|
-
inst = InstanceStatus(
|
|
304
|
-
instance_id=instance_id, component_id=self.COMPONENT_ID,
|
|
305
|
-
model_id=model_id, state=InstanceState.HOT,
|
|
306
|
-
active_jobs=0, queue_depth=0, max_concurrency=4,
|
|
307
|
-
backend=backend, started_at=time.time(), last_heartbeat=time.time(),
|
|
308
|
-
last_error=None, control_mode=ControlMode.IN_PROCESS,
|
|
309
|
-
)
|
|
310
|
-
self._inst_reg.register(inst)
|
|
311
|
-
self._write_state()
|
|
312
|
-
# Phase 4: Heartbeat 시작 (Worker 시작 트리거)
|
|
313
|
-
self._heartbeat.start()
|
|
314
|
-
return {"instance_id": instance_id, "state": InstanceState.HOT.value, "backend": backend}
|
|
315
|
-
|
|
316
|
-
async def drain_instance(self, instance_id: str) -> dict:
|
|
317
|
-
from ameva_component import InstanceNotFound
|
|
318
|
-
if not self._inst_reg.get(instance_id): raise InstanceNotFound(instance_id)
|
|
319
|
-
self._inst_reg.update_state(instance_id, InstanceState.DRAINING)
|
|
320
|
-
return {"instance_id": instance_id, "state": InstanceState.DRAINING.value}
|
|
321
|
-
|
|
322
|
-
async def stop_instance(self, instance_id: str) -> dict:
|
|
323
|
-
from ameva_component import InstanceNotFound
|
|
324
|
-
if not self._inst_reg.get(instance_id): raise InstanceNotFound(instance_id)
|
|
325
|
-
self._inst_reg.update_state(instance_id, InstanceState.STOPPED)
|
|
326
|
-
self._inst_reg.remove(instance_id)
|
|
327
|
-
# Phase 4: Heartbeat 중단 (정상 종료 트리거)
|
|
328
|
-
remaining = self._inst_reg.list_all()
|
|
329
|
-
if not remaining:
|
|
330
|
-
self._heartbeat.stop()
|
|
331
|
-
else:
|
|
332
|
-
self._write_state()
|
|
333
|
-
return {"instance_id": instance_id, "state": InstanceState.STOPPED.value}
|
|
334
|
-
|
|
335
|
-
def _write_state(self, *, ready: bool | None = None, last_error: str | None = None) -> None:
|
|
336
|
-
ts = now_timestamps()
|
|
337
|
-
hot = [i for i in self._inst_reg.list_all() if i.state == InstanceState.HOT]
|
|
338
|
-
_, pid_alive = self._check_pid()
|
|
339
|
-
_ready = True if ready is None else ready # DSP는 항상 최소 가용
|
|
340
|
-
self._state_file.write({
|
|
341
|
-
"protocol": "ameva-component-status/1", "component_id": self.COMPONENT_ID,
|
|
342
|
-
"component_type": self.COMPONENT_TYPE, "version": self._get_version(),
|
|
343
|
-
"ready": _ready, "degraded": not _ready, **ts,
|
|
344
|
-
"active_models": [i.model_id for i in hot], "last_error": last_error,
|
|
345
|
-
})
|
|
346
|
-
|
|
347
|
-
async def synthesize(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
348
|
-
"""AMEVA Component Protocol v1 synthesize method."""
|
|
349
|
-
text = request.get("text", "").strip()
|
|
350
|
-
if not text:
|
|
351
|
-
return {
|
|
352
|
-
"ok": False,
|
|
353
|
-
"error": {
|
|
354
|
-
"code": "TEXT_EMPTY",
|
|
355
|
-
"message": "Input text cannot be empty",
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
voice_id = request.get("voice_id")
|
|
360
|
-
model_id = request.get("model_id")
|
|
361
|
-
output_path = request.get("output_path")
|
|
362
|
-
engine_type = request.get("engine", "auto")
|
|
363
|
-
device = request.get("device", "auto")
|
|
364
|
-
|
|
365
|
-
try:
|
|
366
|
-
from termux_tts.engine import TTSEngine
|
|
367
|
-
model_file = str(self._models_dir / model_id) if model_id else None
|
|
368
|
-
engine = TTSEngine(
|
|
369
|
-
model_path=model_file,
|
|
370
|
-
engine_type=engine_type,
|
|
371
|
-
device=device,
|
|
372
|
-
)
|
|
373
|
-
res = engine.synthesize(text, output=output_path)
|
|
374
|
-
|
|
375
|
-
out_file = output_path
|
|
376
|
-
if not out_file and hasattr(res, "save"):
|
|
377
|
-
import tempfile
|
|
378
|
-
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
|
379
|
-
out_file = f.name
|
|
380
|
-
res.save(out_file)
|
|
381
|
-
|
|
382
|
-
return {
|
|
383
|
-
"ok": True,
|
|
384
|
-
"audio_path": out_file or getattr(res, "output_path", None),
|
|
385
|
-
"duration_sec": getattr(res, "duration_sec", 0.0),
|
|
386
|
-
"sample_rate": getattr(res, "sample_rate", 22050),
|
|
387
|
-
"model_id": getattr(res, "model_name", model_id or "default"),
|
|
388
|
-
"backend": getattr(res, "backend", "unknown"),
|
|
389
|
-
"fallback_used": False,
|
|
390
|
-
}
|
|
391
|
-
except Exception as e:
|
|
392
|
-
return {
|
|
393
|
-
"ok": False,
|
|
394
|
-
"error": {
|
|
395
|
-
"code": "SYNTHESIZE_FAILED",
|
|
396
|
-
"message": str(e),
|
|
397
|
-
}
|
|
398
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
termux_tts.control.errors
|
|
3
|
-
termux-tts 패키지 고유 오류 정의.
|
|
4
|
-
|
|
5
|
-
공통 AmevaError 하위 클래스로 정의하여 render_from_exception()과 호환됩니다.
|
|
6
|
-
"""
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from ameva_component.exceptions import AmevaError
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class VoiceNotFound(AmevaError):
|
|
13
|
-
"""요청한 voice_id가 Registry 또는 내장 목록에 없을 때."""
|
|
14
|
-
|
|
15
|
-
code = "VOICE_NOT_FOUND"
|
|
16
|
-
exit_code = 65
|
|
17
|
-
|
|
18
|
-
def __init__(self, voice_id: str, model_id: str | None = None) -> None:
|
|
19
|
-
where = f" in model '{model_id}'" if model_id else ""
|
|
20
|
-
super().__init__(
|
|
21
|
-
f"Voice '{voice_id}' not found{where}",
|
|
22
|
-
details={"voice_id": voice_id, "model_id": model_id},
|
|
23
|
-
)
|
|
24
|
-
self.voice_id = voice_id
|
|
25
|
-
self.model_id = model_id
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class EngineNotAvailable(AmevaError):
|
|
29
|
-
"""요청한 엔진 백엔드(dsp/onnx/native)가 현재 환경에서 사용 불가능할 때."""
|
|
30
|
-
|
|
31
|
-
code = "ENGINE_NOT_AVAILABLE"
|
|
32
|
-
exit_code = 69
|
|
33
|
-
|
|
34
|
-
def __init__(self, engine_name: str, reason: str) -> None:
|
|
35
|
-
super().__init__(
|
|
36
|
-
f"Engine '{engine_name}' is not available: {reason}",
|
|
37
|
-
details={"engine_name": engine_name, "reason": reason},
|
|
38
|
-
)
|
|
39
|
-
self.engine_name = engine_name
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
class ModelPathRequired(AmevaError):
|
|
43
|
-
"""ONNX 엔진 사용 시 model_path가 없을 때."""
|
|
44
|
-
|
|
45
|
-
code = "MODEL_PATH_REQUIRED"
|
|
46
|
-
exit_code = 64
|
|
47
|
-
|
|
48
|
-
def __init__(self) -> None:
|
|
49
|
-
super().__init__(
|
|
50
|
-
"model_path is required for ONNX engine activation",
|
|
51
|
-
details={"hint": "Pass model_path in the activation request"},
|
|
52
|
-
)
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
termux_tts.control.instances
|
|
3
|
-
InstanceRegistry 연동 — DSP / ONNX / Native 엔진을 각각 Instance로 표현.
|
|
4
|
-
|
|
5
|
-
TTS Instance 정책:
|
|
6
|
-
- DSP 엔진 → instance_id = "tts-dsp"
|
|
7
|
-
- ONNX 엔진 → instance_id = "tts-onnx"
|
|
8
|
-
- Native 엔진 → instance_id = "tts-native"
|
|
9
|
-
- 각 엔진은 in-process ControlMode로 동작 (별도 Worker 프로세스 없음)
|
|
10
|
-
- active_jobs 측정 실패를 0으로 변환하지 않음 → None 반환 후 호출자가 처리
|
|
11
|
-
"""
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import time
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
|
|
17
|
-
from ameva_component import InstanceRegistry
|
|
18
|
-
from ameva_component.instance import ControlMode, InstanceState, InstanceStatus
|
|
19
|
-
|
|
20
|
-
COMPONENT_ID = "termux-tts"
|
|
21
|
-
|
|
22
|
-
# TTS 엔진별 고정 Instance ID
|
|
23
|
-
INSTANCE_DSP = "tts-dsp"
|
|
24
|
-
INSTANCE_ONNX = "tts-onnx"
|
|
25
|
-
INSTANCE_NATIVE = "tts-native"
|
|
26
|
-
|
|
27
|
-
ALL_INSTANCE_IDS = (INSTANCE_DSP, INSTANCE_ONNX, INSTANCE_NATIVE)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
def _make_default_instance(instance_id: str, model_id: str, backend: str) -> InstanceStatus:
|
|
31
|
-
"""엔진별 기본 InstanceStatus를 생성합니다."""
|
|
32
|
-
return InstanceStatus(
|
|
33
|
-
instance_id=instance_id,
|
|
34
|
-
component_id=COMPONENT_ID,
|
|
35
|
-
model_id=model_id,
|
|
36
|
-
state=InstanceState.HOT,
|
|
37
|
-
active_jobs=0,
|
|
38
|
-
queue_depth=0,
|
|
39
|
-
max_concurrency=1,
|
|
40
|
-
backend=backend,
|
|
41
|
-
started_at=time.time(),
|
|
42
|
-
last_heartbeat=time.time(),
|
|
43
|
-
last_error=None,
|
|
44
|
-
control_mode=ControlMode.IN_PROCESS,
|
|
45
|
-
endpoint=None,
|
|
46
|
-
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class TTSInstanceRegistry:
|
|
50
|
-
"""
|
|
51
|
-
InstanceRegistry를 감싸는 TTS 전용 레지스트리.
|
|
52
|
-
|
|
53
|
-
DSP/ONNX/Native 3개 엔진을 각각 독립 Instance로 추적합니다.
|
|
54
|
-
Registry 파일이 없으면 기본 InstanceStatus를 생성하여 등록합니다.
|
|
55
|
-
"""
|
|
56
|
-
|
|
57
|
-
def __init__(self, registry_dir: Path | None = None) -> None:
|
|
58
|
-
self._reg = InstanceRegistry(COMPONENT_ID, registry_dir=registry_dir)
|
|
59
|
-
self._ensure_defaults()
|
|
60
|
-
|
|
61
|
-
# ------------------------------------------------------------------
|
|
62
|
-
# 초기화
|
|
63
|
-
# ------------------------------------------------------------------
|
|
64
|
-
|
|
65
|
-
def _ensure_defaults(self) -> None:
|
|
66
|
-
"""레지스트리에 없는 엔진 Instance를 기본값으로 등록합니다."""
|
|
67
|
-
defaults = {
|
|
68
|
-
INSTANCE_DSP: ("dsp-ko", "dsp-cpu"),
|
|
69
|
-
INSTANCE_ONNX: ("native-system", "onnx-cpu"), # 활성 모델이 없을 때 기본값
|
|
70
|
-
INSTANCE_NATIVE: ("native-system", "android-native"),
|
|
71
|
-
}
|
|
72
|
-
for inst_id, (model_id, backend) in defaults.items():
|
|
73
|
-
if self._reg.get(inst_id) is None:
|
|
74
|
-
self._reg.register(_make_default_instance(inst_id, model_id, backend))
|
|
75
|
-
|
|
76
|
-
# ------------------------------------------------------------------
|
|
77
|
-
# 조회
|
|
78
|
-
# ------------------------------------------------------------------
|
|
79
|
-
|
|
80
|
-
def list_all(self) -> list[dict]:
|
|
81
|
-
"""등록된 모든 Instance를 dict 목록으로 반환합니다."""
|
|
82
|
-
return [
|
|
83
|
-
inst.to_dict()
|
|
84
|
-
for inst in self._reg.list_all()
|
|
85
|
-
]
|
|
86
|
-
|
|
87
|
-
def get(self, instance_id: str) -> InstanceStatus | None:
|
|
88
|
-
"""단일 Instance 상태를 반환합니다. 없으면 None."""
|
|
89
|
-
return self._reg.get(instance_id)
|
|
90
|
-
|
|
91
|
-
def total_active_jobs(self) -> int | None:
|
|
92
|
-
"""
|
|
93
|
-
전체 Instance의 active_jobs 합계를 반환합니다.
|
|
94
|
-
측정 실패 시 None — 0으로 변환 금지.
|
|
95
|
-
"""
|
|
96
|
-
total = 0
|
|
97
|
-
any_read = False
|
|
98
|
-
for inst in self._reg.list_all():
|
|
99
|
-
any_read = True
|
|
100
|
-
total += inst.active_jobs
|
|
101
|
-
return total if any_read else None
|
|
102
|
-
|
|
103
|
-
# ------------------------------------------------------------------
|
|
104
|
-
# 상태 변경
|
|
105
|
-
# ------------------------------------------------------------------
|
|
106
|
-
|
|
107
|
-
def on_model_activated(self, instance_id: str, model_id: str, backend: str) -> None:
|
|
108
|
-
"""모델 활성화 시 Instance model_id와 backend를 갱신합니다."""
|
|
109
|
-
inst = self._reg.get(instance_id)
|
|
110
|
-
if inst is None:
|
|
111
|
-
inst = _make_default_instance(instance_id, model_id, backend)
|
|
112
|
-
inst.model_id = model_id
|
|
113
|
-
inst.backend = backend
|
|
114
|
-
inst.state = InstanceState.HOT
|
|
115
|
-
inst.last_error = None
|
|
116
|
-
inst.last_heartbeat = time.time()
|
|
117
|
-
self._reg.register(inst)
|
|
118
|
-
|
|
119
|
-
def on_job_start(self, instance_id: str) -> None:
|
|
120
|
-
"""Job 시작 시 active_jobs++."""
|
|
121
|
-
self._reg.increment_jobs(instance_id)
|
|
122
|
-
|
|
123
|
-
def on_job_end(self, instance_id: str) -> None:
|
|
124
|
-
"""Job 종료 시 active_jobs-- (음수 방지)."""
|
|
125
|
-
self._reg.decrement_jobs(instance_id)
|
|
126
|
-
|
|
127
|
-
def on_engine_error(self, instance_id: str, error: str) -> None:
|
|
128
|
-
"""엔진 오류 시 Instance 상태를 FAILED로 변경합니다."""
|
|
129
|
-
self._reg.update_state(instance_id, InstanceState.FAILED, last_error=error)
|
|
130
|
-
|
|
131
|
-
def on_heartbeat(self, instance_id: str) -> None:
|
|
132
|
-
"""Heartbeat 갱신."""
|
|
133
|
-
self._reg.update_heartbeat(instance_id)
|
|
134
|
-
|
|
135
|
-
def update_state(self, instance_id: str, state: InstanceState, *, last_error: str | None = None) -> None:
|
|
136
|
-
"""Instance 상태를 직접 변경합니다."""
|
|
137
|
-
self._reg.update_state(instance_id, state, last_error=last_error)
|