termux-tts 1.4.2 → 1.4.3
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/.github/workflows/release.yml +98 -98
- package/CHANGELOG.md +57 -48
- package/LICENSE +17 -17
- package/README.md +441 -438
- package/README.pypi.md +441 -438
- package/bin/cli.js +33 -33
- package/doc.config.yaml +532 -532
- package/docs/benchmarks.md +11 -11
- package/index.js +5 -5
- package/install.sh +53 -53
- package/package.json +57 -57
- package/pyproject.toml +81 -81
- package/setup.py +38 -38
- package/termux_tts/__init__.py +64 -64
- package/termux_tts/control/component.py +398 -398
- package/termux_tts/engine_native.py +104 -104
- package/termux_tts/exceptions.py +28 -28
- package/termux_tts/installer.py +66 -6
- package/termux_tts/tokenizer.py +184 -184
|
@@ -1,398 +1,398 @@
|
|
|
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
|
+
"""
|
|
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
|
+
}
|