termux-tts 0.1.1

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.
Files changed (43) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +31 -0
  3. package/README.pypi.md +47 -0
  4. package/bin/cli.js +33 -0
  5. package/binding_node/index.js +117 -0
  6. package/binding_node/test_node.js +40 -0
  7. package/doc.config.yaml +98 -0
  8. package/docs/benchmarks.md +12 -0
  9. package/docs/guide.md +552 -0
  10. package/docs/tts_guide.md +552 -0
  11. package/dsp_test.wav +0 -0
  12. package/expressive_demo.wav +0 -0
  13. package/g2p_test.wav +0 -0
  14. package/index.js +5 -0
  15. package/install.sh +53 -0
  16. package/package.json +31 -0
  17. package/pyproject.toml +43 -0
  18. package/setup.py +38 -0
  19. package/termux_tts/__init__.py +47 -0
  20. package/termux_tts/adapter.py +99 -0
  21. package/termux_tts/audio.py +75 -0
  22. package/termux_tts/cli.py +82 -0
  23. package/termux_tts/control/__init__.py +4 -0
  24. package/termux_tts/control/component.py +242 -0
  25. package/termux_tts/control/errors.py +52 -0
  26. package/termux_tts/control/instances.py +137 -0
  27. package/termux_tts/control/models.py +151 -0
  28. package/termux_tts/control/status.py +13 -0
  29. package/termux_tts/engine.py +157 -0
  30. package/termux_tts/engine_dsp.py +332 -0
  31. package/termux_tts/engine_native.py +104 -0
  32. package/termux_tts/engine_onnx.py +199 -0
  33. package/termux_tts/exceptions.py +28 -0
  34. package/termux_tts/g2p_korean.py +242 -0
  35. package/termux_tts/tokenizer.py +184 -0
  36. package/termux_tts/vulkan_probe.py +25 -0
  37. package/test_cli.wav +0 -0
  38. package/tests/test_expressive_presets.py +29 -0
  39. package/tests/test_g2p_korean.py +73 -0
  40. package/tests/test_granular_tts.py +141 -0
  41. package/tests/test_native_engine.py +38 -0
  42. package/tests/test_onnx_engine.py +48 -0
  43. package/tests/test_vulkan_routing.py +52 -0
@@ -0,0 +1,52 @@
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
+ )
@@ -0,0 +1,137 @@
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)
@@ -0,0 +1,151 @@
1
+ """
2
+ termux_tts.control.models
3
+ ModelRegistry 연동 — TTS 모델 + Voice 분리 관리.
4
+
5
+ TTS 특이사항:
6
+ - model_id: "dsp-ko", "piper-ko", "fastspeech-ko" 등 엔진/언어 조합
7
+ - voice_id: "ko-speaker-01", "en-speaker-01" 등 화자 ID (모델에 종속)
8
+ - 내장 엔진(dsp/native)은 파일 없이도 항상 사용 가능 → missing 상태로 표기 금지
9
+ - ONNX 모델은 파일 존재만으로 installed 처리 금지 → Registry 기반
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ameva_component import ModelRegistry
19
+ from ameva_component.manifest import ModelState
20
+
21
+ COMPONENT_ID = "termux-tts"
22
+
23
+ # ------------------------------------------------------------------
24
+ # 내장(Built-in) 엔진 정의 — 파일 불필요, 항상 사용 가능
25
+ # ------------------------------------------------------------------
26
+
27
+ # format: {model_id: {description, engine, voices, backend_key}}
28
+ BUILTIN_MODELS: dict[str, dict[str, Any]] = {
29
+ "dsp-ko": {
30
+ "model_id": "dsp-ko",
31
+ "description": "Pure Parametric DSP Formant Synthesizer (Korean, zero-dependency)",
32
+ "engine": "dsp",
33
+ "format": "builtin",
34
+ "language": "ko",
35
+ "voices": [
36
+ {"voice_id": "ko-default", "description": "Default Korean DSP voice", "language": "ko"},
37
+ ],
38
+ "state": ModelState.ACTIVE.value, # 내장 엔진은 항상 사용 가능
39
+ },
40
+ "dsp-en": {
41
+ "model_id": "dsp-en",
42
+ "description": "Pure Parametric DSP Formant Synthesizer (English, zero-dependency)",
43
+ "engine": "dsp",
44
+ "format": "builtin",
45
+ "language": "en",
46
+ "voices": [
47
+ {"voice_id": "en-default", "description": "Default English DSP voice", "language": "en"},
48
+ ],
49
+ "state": ModelState.ACTIVE.value,
50
+ },
51
+ "native-system": {
52
+ "model_id": "native-system",
53
+ "description": "Android System Native Voice (Samsung/Google TTS bridge)",
54
+ "engine": "native",
55
+ "format": "builtin",
56
+ "language": "ko,en",
57
+ "voices": [
58
+ {"voice_id": "system-default", "description": "Android system TTS voice", "language": "ko,en"},
59
+ ],
60
+ "state": ModelState.ACTIVE.value,
61
+ },
62
+ }
63
+
64
+
65
+ class TTSModelRegistry:
66
+ """
67
+ ModelRegistry를 감싸는 TTS 전용 레지스트리.
68
+
69
+ 내장 엔진(dsp, native)은 파일 없이도 항상 사용 가능하며,
70
+ ONNX 모델만 ModelRegistry를 통해 상태를 추적합니다.
71
+ """
72
+
73
+ def __init__(self, registry_dir: Path | None = None) -> None:
74
+ self._reg = ModelRegistry(COMPONENT_ID, registry_dir=registry_dir)
75
+
76
+ # ------------------------------------------------------------------
77
+ # 전체 목록 조회
78
+ # ------------------------------------------------------------------
79
+
80
+ def list_all(self) -> list[dict]:
81
+ """
82
+ 모든 모델 레코드를 반환합니다.
83
+ - 내장 엔진: BUILTIN_MODELS에서 직접 반환
84
+ - ONNX 모델: ModelRegistry에서 실제 상태 조회
85
+ """
86
+ result: list[dict] = list(BUILTIN_MODELS.values())
87
+ # Registry에서 외부 ONNX 모델 추가
88
+ for rec in self._reg.list_all():
89
+ result.append(self._enrich_onnx_record(rec))
90
+ return result
91
+
92
+ def list_voices_for_model(self, model_id: str) -> list[dict]:
93
+ """특정 model_id의 voice 목록을 반환합니다."""
94
+ if model_id in BUILTIN_MODELS:
95
+ return BUILTIN_MODELS[model_id].get("voices", [])
96
+ rec = self._reg.get(model_id)
97
+ if rec is None:
98
+ return []
99
+ return rec.get("voices", [])
100
+
101
+ def get(self, model_id: str) -> dict | None:
102
+ """단일 모델 레코드를 반환합니다. 없으면 None."""
103
+ if model_id in BUILTIN_MODELS:
104
+ return BUILTIN_MODELS[model_id]
105
+ rec = self._reg.get(model_id)
106
+ if rec is None:
107
+ return None
108
+ return self._enrich_onnx_record(rec)
109
+
110
+ def get_state(self, model_id: str) -> ModelState | None:
111
+ """모델 상태를 반환합니다. 없으면 None."""
112
+ if model_id in BUILTIN_MODELS:
113
+ return ModelState.ACTIVE
114
+ rec = self._reg.get(model_id)
115
+ if rec is None:
116
+ return None
117
+ try:
118
+ return ModelState(rec.get("state", "missing"))
119
+ except ValueError:
120
+ return None
121
+
122
+ # ------------------------------------------------------------------
123
+ # 상태 변경
124
+ # ------------------------------------------------------------------
125
+
126
+ def set_onnx_active(self, model_id: str) -> None:
127
+ """ONNX 모델을 active 상태로 변경합니다."""
128
+ self._reg.set_state(model_id, ModelState.ACTIVE)
129
+
130
+ def set_onnx_inactive(self, model_id: str) -> None:
131
+ """ONNX 모델을 inactive 상태로 변경합니다."""
132
+ self._reg.set_state(model_id, ModelState.INACTIVE)
133
+
134
+ def record_onnx_install(self, model_id: str, manifest, verified_at: float) -> None:
135
+ """ONNX 모델 설치 완료를 기록합니다."""
136
+ self._reg.record_install(model_id, manifest, verified_at)
137
+
138
+ def set_onnx_state(self, model_id: str, state: ModelState, *, last_error: str | None = None) -> None:
139
+ """ONNX 모델 상태를 직접 갱신합니다."""
140
+ self._reg.set_state(model_id, state, last_error=last_error)
141
+
142
+ # ------------------------------------------------------------------
143
+ # 내부 헬퍼
144
+ # ------------------------------------------------------------------
145
+
146
+ def _enrich_onnx_record(self, rec: dict) -> dict:
147
+ """ONNX 레코드에 engine/voices 필드가 없으면 기본값을 추가합니다."""
148
+ enriched = dict(rec)
149
+ enriched.setdefault("engine", "onnx")
150
+ enriched.setdefault("voices", [])
151
+ return enriched
@@ -0,0 +1,13 @@
1
+ """
2
+ termux_tts.control.status — TTS 상태 파일 Writer + Heartbeat
3
+ """
4
+ from __future__ import annotations
5
+ from typing import Any
6
+ from ameva_component.heartbeat import HeartbeatWriter
7
+
8
+
9
+ class TTSStatusWriter(HeartbeatWriter):
10
+ """TTSControl 상태를 10초마다 상태 파일에 원자적으로 기록합니다."""
11
+
12
+ def __init__(self, control: Any) -> None:
13
+ super().__init__(control, name="tts")
@@ -0,0 +1,157 @@
1
+ """
2
+ Unified 3-Tier Multi-Backend TTS Gateway for termux-tts:
3
+ 1. Pure Parametric DSP Formant Synthesizer Engine (ParametricDSPEngine - 0MB Zero-Dependency)
4
+ 2. Authentic ONNX Runtime Neural Vocoder Engine (ONNXNeuralEngine - Deep Learning)
5
+ 3. Android System Native Samsung/Google Voice Engine Bridge (NativeAndroidEngine)
6
+ """
7
+
8
+ import os
9
+ from typing import Optional, Union, Dict, Any
10
+
11
+ from .exceptions import TTSInferenceError
12
+ from .engine_native import NativeAndroidEngine, NativeResult
13
+ from .engine_dsp import ParametricDSPEngine, DSPResult, QUALITY_PRESETS
14
+ from .engine_onnx import ONNXNeuralEngine, ONNXResult
15
+
16
+ class TTSEngine:
17
+ """Production Multi-Backend Gateway supporting DSP, ONNX, and Native speech engines."""
18
+
19
+ def __init__(
20
+ self,
21
+ model_path: Optional[str] = None,
22
+ language: str = "ko",
23
+ preset: str = "balanced",
24
+ device: str = "auto",
25
+ sample_rate: Optional[int] = None,
26
+ engine_type: str = "auto"
27
+ ):
28
+ self.language = language.lower()
29
+ self.preset = preset.lower()
30
+ self.requested_device = device.lower()
31
+ self.requested_engine_type = engine_type.lower()
32
+ self.model_path = model_path
33
+ self.sample_rate = sample_rate
34
+ self._is_closed = False
35
+
36
+ self.native_engine = NativeAndroidEngine(language=language)
37
+ self.synth_engine = self._resolve_synth_engine()
38
+
39
+ def _resolve_synth_engine(self):
40
+ if self.requested_engine_type == "onnx":
41
+ return ONNXNeuralEngine(
42
+ model_path=self.model_path,
43
+ language=self.language,
44
+ preset=self.preset,
45
+ device=self.requested_device,
46
+ sample_rate=self.sample_rate or 22050
47
+ )
48
+ elif self.requested_engine_type in ("dsp", "formant"):
49
+ return ParametricDSPEngine(
50
+ model_path=self.model_path,
51
+ language=self.language,
52
+ preset=self.preset,
53
+ device=self.requested_device,
54
+ sample_rate=self.sample_rate
55
+ )
56
+ elif self.requested_engine_type == "native":
57
+ return self.native_engine
58
+ elif self.requested_engine_type == "auto":
59
+ if self.model_path and os.path.isfile(self.model_path):
60
+ try:
61
+ return ONNXNeuralEngine(
62
+ model_path=self.model_path,
63
+ language=self.language,
64
+ preset=self.preset,
65
+ device=self.requested_device,
66
+ sample_rate=self.sample_rate or 22050
67
+ )
68
+ except Exception:
69
+ pass
70
+ return ParametricDSPEngine(
71
+ model_path=self.model_path,
72
+ language=self.language,
73
+ preset=self.preset,
74
+ device=self.requested_device,
75
+ sample_rate=self.sample_rate
76
+ )
77
+ else:
78
+ raise TTSInferenceError(
79
+ f"Unknown engine_type '{self.requested_engine_type}'. Available: ['auto', 'dsp', 'onnx', 'native']"
80
+ )
81
+
82
+ @property
83
+ def active_backend(self) -> str:
84
+ return getattr(self.synth_engine, "backend", "NATIVE_SYSTEM")
85
+
86
+ @property
87
+ def model_name(self) -> str:
88
+ return getattr(self.synth_engine, "model_name", "native-system-voice")
89
+
90
+ @property
91
+ def binary(self) -> Optional[str]:
92
+ return getattr(self.native_engine, "binary", None)
93
+
94
+ def speak(self, text: str, stream: Optional[str] = None) -> NativeResult:
95
+ """Speak text directly through physical Android speaker (Native Engine)."""
96
+ if self._is_closed:
97
+ raise TTSInferenceError("Cannot speak: Engine session is closed.")
98
+ return self.native_engine.speak(text, stream=stream)
99
+
100
+ def synthesize(self, text: str, output: Optional[str] = None, speed: float = 1.0, preset: Optional[str] = None) -> Union[DSPResult, ONNXResult]:
101
+ """Synthesize text into speech audio buffer / WAV file (DSP or ONNX Engine)."""
102
+ if self._is_closed:
103
+ raise TTSInferenceError("Cannot synthesize: Engine session is closed.")
104
+ return self.synth_engine.synthesize(text, output=output, speed=speed, preset=preset)
105
+
106
+ def close(self) -> None:
107
+ self._is_closed = True
108
+ self.native_engine.close()
109
+ if hasattr(self.synth_engine, "close"):
110
+ self.synth_engine.close()
111
+
112
+ def __enter__(self):
113
+ return self
114
+
115
+ def __exit__(self, exc_type, exc_val, exc_tb):
116
+ self.close()
117
+
118
+ def load(
119
+ model: Optional[str] = None,
120
+ language: str = "ko",
121
+ preset: str = "balanced",
122
+ device: str = "auto",
123
+ sample_rate: Optional[int] = None,
124
+ engine: str = "auto"
125
+ ) -> TTSEngine:
126
+ return TTSEngine(
127
+ model_path=model,
128
+ language=language,
129
+ preset=preset,
130
+ device=device,
131
+ sample_rate=sample_rate,
132
+ engine_type=engine
133
+ )
134
+
135
+ def doctor() -> Dict[str, Any]:
136
+ try:
137
+ import ameva_vulkan_runtime as avr
138
+ from ameva_vulkan_runtime.adapters import TtsAdapter
139
+ doc = avr.Doctor()
140
+ rep = doc.run_self_test(verbose=False)
141
+ return {
142
+ "doctor_report": rep,
143
+ "overall_success": getattr(rep, "overall_success", False),
144
+ "passed_stages": getattr(rep, "passed_stages", 0),
145
+ "recommended_backend": getattr(rep, "recommended_backend", "cpu"),
146
+ "status": "DIAGNOSED_VIA_AMEVA"
147
+ }
148
+ except Exception as e:
149
+ return {
150
+ "doctor_report": None,
151
+ "overall_success": False,
152
+ "passed_stages": 0,
153
+ "recommended_backend": "cpu_neon",
154
+ "error": str(e),
155
+ "status": "FALLBACK_CPU"
156
+ }
157
+