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,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)