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.
@@ -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
+ }