termux-tts 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +40 -22
- package/package.json +9 -1
- package/.github/workflows/release.yml +0 -98
- package/CHANGELOG.md +0 -93
- package/doc.config.yaml +0 -595
- package/docs/benchmarks.md +0 -12
- package/docs/guide.md +0 -552
- package/docs/tts_guide.md +0 -552
- package/install.sh +0 -53
- package/pyproject.toml +0 -81
- package/setup.py +0 -38
- package/termux_tts/__init__.py +0 -70
- package/termux_tts/adapter.py +0 -202
- package/termux_tts/audio.py +0 -101
- package/termux_tts/cli.py +0 -156
- package/termux_tts/control/__init__.py +0 -4
- package/termux_tts/control/component.py +0 -398
- package/termux_tts/control/errors.py +0 -52
- package/termux_tts/control/instances.py +0 -137
- package/termux_tts/control/models.py +0 -151
- package/termux_tts/control/status.py +0 -13
- package/termux_tts/engine.py +0 -340
- package/termux_tts/engine_expressive.py +0 -216
- package/termux_tts/engine_multilingual.py +0 -409
- package/termux_tts/engine_native.py +0 -104
- package/termux_tts/engine_onnx.py +0 -8
- package/termux_tts/engine_sherpa.py +0 -388
- package/termux_tts/engine_sherpa_capi.py +0 -508
- package/termux_tts/engine_vulkan.py +0 -395
- package/termux_tts/exceptions.py +0 -28
- package/termux_tts/g2p_korean.py +0 -242
- package/termux_tts/hardware.py +0 -166
- package/termux_tts/installer.py +0 -381
- package/termux_tts/script_classifier.py +0 -253
- package/termux_tts/tokenizer.py +0 -186
- package/termux_tts/tokenizer_melo.py +0 -159
- package/termux_tts/transliteration.py +0 -250
package/setup.py
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from setuptools import setup, find_packages
|
|
3
|
-
|
|
4
|
-
setup(
|
|
5
|
-
name="termux-tts",
|
|
6
|
-
version="1.5.0",
|
|
7
|
-
description="Ultra-Fast On-Device 4-Tier Text-to-Speech Framework (DSP Synth, Android Native, C++ Sherpa-ONNX Neural & Expressive)",
|
|
8
|
-
long_description=open("README.pypi.md", encoding="utf-8").read() if os.path.exists("README.pypi.md") else open("README.md", encoding="utf-8").read(),
|
|
9
|
-
long_description_content_type="text/markdown",
|
|
10
|
-
author="AMEVA Foundation",
|
|
11
|
-
license="Apache-2.0",
|
|
12
|
-
url="https://github.com/uno-km/termux-tts",
|
|
13
|
-
packages=find_packages(include=["termux_tts", "termux_tts.*"]),
|
|
14
|
-
python_requires=">=3.8",
|
|
15
|
-
install_requires=[
|
|
16
|
-
"numpy>=1.20.0",
|
|
17
|
-
"ameva-runtime>=2.0.0",
|
|
18
|
-
],
|
|
19
|
-
extras_require={
|
|
20
|
-
"onnx": ["onnxruntime>=1.15.0"],
|
|
21
|
-
"neural": ["onnxruntime>=1.15.0"],
|
|
22
|
-
"dev": ["pytest>=7.0.0", "pytest-asyncio"],
|
|
23
|
-
},
|
|
24
|
-
entry_points={
|
|
25
|
-
"console_scripts": [
|
|
26
|
-
"termux-tts=termux_tts.cli:main",
|
|
27
|
-
],
|
|
28
|
-
},
|
|
29
|
-
classifiers=[
|
|
30
|
-
"Development Status :: 5 - Production/Stable",
|
|
31
|
-
"Intended Audience :: Developers",
|
|
32
|
-
"License :: OSI Approved :: Apache Software License",
|
|
33
|
-
"Operating System :: Android",
|
|
34
|
-
"Operating System :: POSIX :: Linux",
|
|
35
|
-
"Programming Language :: Python :: 3",
|
|
36
|
-
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
37
|
-
],
|
|
38
|
-
)
|
package/termux_tts/__init__.py
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
termux-tts: Production-Grade 4-Tier TTS Framework for Android Termux.
|
|
3
|
-
- Tier 1: Parametric DSP Formant Synthesizer Engine (0MB Zero-Dependency)
|
|
4
|
-
- Tier 2: Android System Native Voice Engine Bridge (Samsung / Google Voice)
|
|
5
|
-
- Tier 3: Authentic C++ Subprocess-Isolated Sherpa-ONNX Neural Vocoder (VITS Deep Learning)
|
|
6
|
-
- Tier 4: Pure On-Device Expressive Emotional Synthesizer (Conversational Tags)
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from .engine import TTSEngine, load, doctor
|
|
10
|
-
from .engine_native import NativeAndroidEngine, NativeResult
|
|
11
|
-
from .engine_sherpa import SherpaNeuralEngine, SherpaResult
|
|
12
|
-
from .engine_vulkan import VulkanNeuralEngine, VulkanResult
|
|
13
|
-
from .engine_expressive import ExpressiveEngine, ExpressiveResult
|
|
14
|
-
from .engine_multilingual import MultilingualNeuralEngine, MultilingualResult
|
|
15
|
-
from .engine_sherpa_capi import SherpaResidentManager, SherpaCapiSession
|
|
16
|
-
from .script_classifier import MultilingualTokenizer, ScriptRegistry, LanguageChunk
|
|
17
|
-
from .installer import run_installation
|
|
18
|
-
from .tokenizer import PhoneticTokenizer, EXPRESSIVE_TAGS
|
|
19
|
-
from .g2p_korean import KoreanG2PEngine, korean_text_to_phonemes
|
|
20
|
-
from .audio import AudioBuffer
|
|
21
|
-
from .exceptions import (
|
|
22
|
-
TTSError,
|
|
23
|
-
TTSModelLoadError,
|
|
24
|
-
TTSInferenceError,
|
|
25
|
-
VulkanInitializationError,
|
|
26
|
-
TTSAudioEncodingError,
|
|
27
|
-
TTSLanguageNotSupportedError
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
# Backward-compatibility alias
|
|
31
|
-
ONNXNeuralEngine = SherpaNeuralEngine
|
|
32
|
-
ONNXResult = SherpaResult
|
|
33
|
-
|
|
34
|
-
__version__ = "1.5.0"
|
|
35
|
-
__all__ = [
|
|
36
|
-
"TTSEngine",
|
|
37
|
-
"load",
|
|
38
|
-
"doctor",
|
|
39
|
-
"NativeAndroidEngine",
|
|
40
|
-
"NativeResult",
|
|
41
|
-
"SherpaNeuralEngine",
|
|
42
|
-
"SherpaResult",
|
|
43
|
-
"VulkanNeuralEngine",
|
|
44
|
-
"VulkanResult",
|
|
45
|
-
"ExpressiveEngine",
|
|
46
|
-
"ExpressiveResult",
|
|
47
|
-
"MultilingualNeuralEngine",
|
|
48
|
-
"MultilingualResult",
|
|
49
|
-
"SherpaResidentManager",
|
|
50
|
-
"SherpaCapiSession",
|
|
51
|
-
"MultilingualTokenizer",
|
|
52
|
-
"ScriptRegistry",
|
|
53
|
-
"LanguageChunk",
|
|
54
|
-
"run_installation",
|
|
55
|
-
"ONNXNeuralEngine",
|
|
56
|
-
"ONNXResult",
|
|
57
|
-
"QUALITY_PRESETS",
|
|
58
|
-
"PhoneticTokenizer",
|
|
59
|
-
"KoreanG2PEngine",
|
|
60
|
-
"korean_text_to_phonemes",
|
|
61
|
-
"EXPRESSIVE_TAGS",
|
|
62
|
-
"AudioBuffer",
|
|
63
|
-
"TTSError",
|
|
64
|
-
"TTSModelLoadError",
|
|
65
|
-
"TTSInferenceError",
|
|
66
|
-
"VulkanInitializationError",
|
|
67
|
-
"TTSAudioEncodingError",
|
|
68
|
-
"TTSLanguageNotSupportedError"
|
|
69
|
-
]
|
|
70
|
-
|
package/termux_tts/adapter.py
DELETED
|
@@ -1,202 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
termux_tts.adapter
|
|
3
|
-
===================
|
|
4
|
-
AMEVA Component Protocol v1 — Orchestrator Adapter (v0.8.1 호환)
|
|
5
|
-
|
|
6
|
-
P0-2: infer() fallback yield _not_supported → raise OperationNotSupported
|
|
7
|
-
P0-4: except Exception → retryable 분류, 원본 오류 코드 보존
|
|
8
|
-
"""
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from typing import Any, AsyncIterator
|
|
12
|
-
|
|
13
|
-
from ameva_component.adapter_base import BaseOrchestratorAdapter
|
|
14
|
-
from ameva_component.exceptions import (
|
|
15
|
-
ComponentError,
|
|
16
|
-
OperationNotSupported,
|
|
17
|
-
redact_details,
|
|
18
|
-
redact_text,
|
|
19
|
-
)
|
|
20
|
-
from termux_tts.control.component import TTSControl
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class TTSOrchestratorAdapter(BaseOrchestratorAdapter):
|
|
24
|
-
"""TTS (Text-to-Speech) Orchestrator Adapter.
|
|
25
|
-
|
|
26
|
-
합성은 파일 기반으로 수행됩니다.
|
|
27
|
-
infer(): text를 받아 audio 파일 경로를 반환합니다.
|
|
28
|
-
요청 모델과 실행 모델이 다르면 fallback_used=True를 명시합니다.
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
COMPONENT_ID = "termux-tts"
|
|
32
|
-
|
|
33
|
-
_RETRYABLE_CODES: frozenset[str] = frozenset({
|
|
34
|
-
"REMOTE_TIMEOUT",
|
|
35
|
-
"MODEL_BUSY",
|
|
36
|
-
"TEMPORARY_RESOURCE_UNAVAILABLE",
|
|
37
|
-
})
|
|
38
|
-
_NON_RETRYABLE_CODES: frozenset[str] = frozenset({
|
|
39
|
-
"VOICE_NOT_FOUND",
|
|
40
|
-
"TEXT_TOO_LONG",
|
|
41
|
-
"UNSUPPORTED_LANGUAGE",
|
|
42
|
-
"OUTPUT_PATH_FORBIDDEN",
|
|
43
|
-
"ENCODING_FAILED",
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
def __init__(self, control: TTSControl | None = None) -> None:
|
|
47
|
-
self._control = control or TTSControl()
|
|
48
|
-
|
|
49
|
-
async def infer(self, request: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
|
|
50
|
-
"""TTS synthesis: text → audio file path.
|
|
51
|
-
|
|
52
|
-
request 키:
|
|
53
|
-
text (str): 합성할 텍스트 (필수, 비어있으면 안 됨)
|
|
54
|
-
voice_id (str): 목소리 ID (선택)
|
|
55
|
-
model_id (str): 모델 ID (선택)
|
|
56
|
-
output_path (str): 출력 파일 경로 (선택)
|
|
57
|
-
|
|
58
|
-
반환 프레임:
|
|
59
|
-
{"type": "audio", "audio_path": str, "final": bool, "fallback_used": bool}
|
|
60
|
-
{"type": "error", "ok": False, "error": {...}}
|
|
61
|
-
"""
|
|
62
|
-
text = request.get("text", "").strip()
|
|
63
|
-
if not text:
|
|
64
|
-
yield {
|
|
65
|
-
"type": "error",
|
|
66
|
-
"ok": False,
|
|
67
|
-
"error": {
|
|
68
|
-
"code": "TEXT_EMPTY",
|
|
69
|
-
"message": "text is required and must not be empty",
|
|
70
|
-
"operation": "infer",
|
|
71
|
-
"component_id": self.COMPONENT_ID,
|
|
72
|
-
"retryable": False,
|
|
73
|
-
},
|
|
74
|
-
}
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
if not hasattr(self._control, "synthesize"):
|
|
78
|
-
# P0-2: fallback yield → raise
|
|
79
|
-
raise OperationNotSupported(operation="infer.synthesize", component_id=self.COMPONENT_ID)
|
|
80
|
-
|
|
81
|
-
try:
|
|
82
|
-
result = await self._control.synthesize(request)
|
|
83
|
-
|
|
84
|
-
if not isinstance(result, dict):
|
|
85
|
-
raise ValueError(f"synthesize() must return dict, got {type(result).__name__}")
|
|
86
|
-
|
|
87
|
-
if result.get("ok") is not True:
|
|
88
|
-
err_payload = result.get("error") if isinstance(result.get("error"), dict) else {}
|
|
89
|
-
yield {
|
|
90
|
-
"type": "error",
|
|
91
|
-
"ok": False,
|
|
92
|
-
"error": {
|
|
93
|
-
"code": err_payload.get("code", "ADAPTER_RESULT_NOT_SUCCESS"),
|
|
94
|
-
"message": err_payload.get("message", "synthesize() did not return ok=True"),
|
|
95
|
-
"operation": "infer",
|
|
96
|
-
"component_id": self.COMPONENT_ID,
|
|
97
|
-
"retryable": False,
|
|
98
|
-
"details": {"result_keys": sorted(result.keys())},
|
|
99
|
-
},
|
|
100
|
-
}
|
|
101
|
-
return
|
|
102
|
-
|
|
103
|
-
audio_path = result.get("audio_path")
|
|
104
|
-
if not audio_path:
|
|
105
|
-
raise ValueError("synthesize() result missing required field 'audio_path'")
|
|
106
|
-
if not isinstance(audio_path, str):
|
|
107
|
-
raise TypeError(f"synthesize() audio_path must be str, got {type(audio_path).__name__}")
|
|
108
|
-
|
|
109
|
-
yield {
|
|
110
|
-
"type": "audio",
|
|
111
|
-
"audio_path": audio_path,
|
|
112
|
-
"final": True,
|
|
113
|
-
"fallback_used": result.get("fallback_used", False),
|
|
114
|
-
"requested_voice": result.get("requested_voice"),
|
|
115
|
-
"executed_voice": result.get("executed_voice"),
|
|
116
|
-
"ok": True,
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
except ComponentError as component_err:
|
|
120
|
-
# HIGH 1: ComponentError public_message와 redact_details로 외부 노출 보안 격리
|
|
121
|
-
import logging
|
|
122
|
-
logging.getLogger(__name__).exception(
|
|
123
|
-
"TTS component operation failed",
|
|
124
|
-
extra={
|
|
125
|
-
"component_id": self.COMPONENT_ID,
|
|
126
|
-
"operation": "infer",
|
|
127
|
-
"code": getattr(component_err, "code", "COMPONENT_ERROR"),
|
|
128
|
-
},
|
|
129
|
-
)
|
|
130
|
-
if hasattr(component_err, "to_public_dict"):
|
|
131
|
-
err_dict = component_err.to_public_dict()
|
|
132
|
-
else:
|
|
133
|
-
err_dict = {
|
|
134
|
-
"code": getattr(component_err, "code", "COMPONENT_ERROR"),
|
|
135
|
-
"message": redact_text(getattr(component_err, "public_message", "Component operation failed")),
|
|
136
|
-
"retryable": getattr(component_err, "retryable", False),
|
|
137
|
-
"details": redact_details(getattr(component_err, "details", {})),
|
|
138
|
-
}
|
|
139
|
-
yield {
|
|
140
|
-
"type": "error",
|
|
141
|
-
"ok": False,
|
|
142
|
-
"error": {
|
|
143
|
-
**err_dict,
|
|
144
|
-
"operation": "infer",
|
|
145
|
-
"component_id": self.COMPONENT_ID,
|
|
146
|
-
"retryable": self._classify_retryable(
|
|
147
|
-
err_dict.get("code", ""), default=err_dict.get("retryable", False)
|
|
148
|
-
),
|
|
149
|
-
},
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
except (ValueError, TypeError) as contract_err:
|
|
153
|
-
# P0-3/4 & HIGH 1: 계약 위반 (str(contract_err) 직접 노출 금지, 내부 로그 격리)
|
|
154
|
-
import logging
|
|
155
|
-
logging.getLogger(__name__).exception("TTS contract validation error during infer: %s", contract_err)
|
|
156
|
-
yield {
|
|
157
|
-
"type": "error",
|
|
158
|
-
"ok": False,
|
|
159
|
-
"error": {
|
|
160
|
-
"code": "ADAPTER_CONTRACT_ERROR",
|
|
161
|
-
"message": "Adapter contract validation failed",
|
|
162
|
-
"operation": "infer",
|
|
163
|
-
"component_id": self.COMPONENT_ID,
|
|
164
|
-
"retryable": False,
|
|
165
|
-
"details": {
|
|
166
|
-
"cause_type": type(contract_err).__name__,
|
|
167
|
-
"operation": "infer",
|
|
168
|
-
},
|
|
169
|
-
},
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
except Exception as unexpected_err:
|
|
173
|
-
import logging
|
|
174
|
-
logging.getLogger(__name__).exception("TTS adapter unexpected error during infer: %s", unexpected_err)
|
|
175
|
-
code = getattr(unexpected_err, "code", "ADAPTER_INTERNAL_ERROR")
|
|
176
|
-
yield {
|
|
177
|
-
"type": "error",
|
|
178
|
-
"ok": False,
|
|
179
|
-
"error": {
|
|
180
|
-
"code": code if isinstance(code, str) else "ADAPTER_INTERNAL_ERROR",
|
|
181
|
-
"message": "Unexpected adapter failure",
|
|
182
|
-
"operation": "infer",
|
|
183
|
-
"component_id": self.COMPONENT_ID,
|
|
184
|
-
"retryable": False,
|
|
185
|
-
"details": {
|
|
186
|
-
"cause_type": type(unexpected_err).__name__,
|
|
187
|
-
"operation": "infer",
|
|
188
|
-
},
|
|
189
|
-
},
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
def _classify_retryable(self, code: str, *, default: bool = False) -> bool:
|
|
193
|
-
if code in self._RETRYABLE_CODES:
|
|
194
|
-
return True
|
|
195
|
-
if code in self._NON_RETRYABLE_CODES:
|
|
196
|
-
return False
|
|
197
|
-
return default
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def create_adapter() -> TTSOrchestratorAdapter:
|
|
201
|
-
"""Entry Point Factory."""
|
|
202
|
-
return TTSOrchestratorAdapter()
|
package/termux_tts/audio.py
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Zero-Dependency Audio Buffer and RIFF WAV Stream Encoder.
|
|
3
|
-
Handles 16-bit Linear PCM formatting with soft-clipping protection.
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import io
|
|
7
|
-
import wave
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Union
|
|
10
|
-
import numpy as np
|
|
11
|
-
from .exceptions import TTSAudioEncodingError
|
|
12
|
-
|
|
13
|
-
class AudioBuffer:
|
|
14
|
-
"""Manages raw floating-point audio samples and converts to 16-bit PCM WAV."""
|
|
15
|
-
|
|
16
|
-
def __init__(self, samples: Union[np.ndarray, list], sample_rate: int = 22050):
|
|
17
|
-
if isinstance(samples, list):
|
|
18
|
-
self.samples = np.array(samples, dtype=np.float32)
|
|
19
|
-
elif isinstance(samples, np.ndarray):
|
|
20
|
-
self.samples = samples.astype(np.float32).flatten()
|
|
21
|
-
else:
|
|
22
|
-
raise TTSAudioEncodingError("Samples must be a list or numpy ndarray.")
|
|
23
|
-
|
|
24
|
-
self.sample_rate = sample_rate
|
|
25
|
-
self._normalize_and_clip()
|
|
26
|
-
|
|
27
|
-
def _normalize_and_clip(self) -> None:
|
|
28
|
-
"""Apply peak normalization and soft clipping to prevent distortion."""
|
|
29
|
-
if len(self.samples) == 0:
|
|
30
|
-
return
|
|
31
|
-
|
|
32
|
-
peak = np.max(np.abs(self.samples))
|
|
33
|
-
if peak > 1.0:
|
|
34
|
-
self.samples = self.samples / peak
|
|
35
|
-
elif peak < 0.0001:
|
|
36
|
-
pass # Keep quiet signals as is
|
|
37
|
-
else:
|
|
38
|
-
# Gentle scale to target -1dB
|
|
39
|
-
self.samples = self.samples * 0.95
|
|
40
|
-
|
|
41
|
-
@property
|
|
42
|
-
def duration_seconds(self) -> float:
|
|
43
|
-
"""Duration of the audio in seconds."""
|
|
44
|
-
if self.sample_rate <= 0:
|
|
45
|
-
return 0.0
|
|
46
|
-
return len(self.samples) / float(self.sample_rate)
|
|
47
|
-
|
|
48
|
-
def to_pcm16_bytes(self) -> bytes:
|
|
49
|
-
"""Convert float32 [-1.0, 1.0] samples into 16-bit signed integer bytes."""
|
|
50
|
-
clean = np.nan_to_num(self.samples, nan=0.0, posinf=1.0, neginf=-1.0)
|
|
51
|
-
clipped = np.clip(clean, -1.0, 1.0)
|
|
52
|
-
pcm16 = (clipped * 32767.0).astype(np.int16)
|
|
53
|
-
return pcm16.tobytes()
|
|
54
|
-
|
|
55
|
-
def to_wav_bytes(self) -> bytes:
|
|
56
|
-
"""Encode raw samples into standard RIFF WAV format."""
|
|
57
|
-
try:
|
|
58
|
-
pcm_bytes = self.to_pcm16_bytes()
|
|
59
|
-
buf = io.BytesIO()
|
|
60
|
-
with wave.open(buf, "wb") as wav_file:
|
|
61
|
-
wav_file.setnchannels(1) # Mono
|
|
62
|
-
wav_file.setsampwidth(2) # 16-bit (2 bytes)
|
|
63
|
-
wav_file.setframerate(self.sample_rate)
|
|
64
|
-
wav_file.writeframes(pcm_bytes)
|
|
65
|
-
return buf.getvalue()
|
|
66
|
-
except Exception as e:
|
|
67
|
-
raise TTSAudioEncodingError(f"Failed to encode WAV buffer: {e}") from e
|
|
68
|
-
|
|
69
|
-
def pad_silence(self, lead_in_ms: int = 200, lead_out_ms: int = 150) -> "AudioBuffer":
|
|
70
|
-
"""Prepend and append true zero-amplitude silence to prevent Android DAC ramp-up clipping."""
|
|
71
|
-
lead_in_samples = int(self.sample_rate * (lead_in_ms / 1000.0))
|
|
72
|
-
lead_out_samples = int(self.sample_rate * (lead_out_ms / 1000.0))
|
|
73
|
-
zeros_in = np.zeros(lead_in_samples, dtype=np.float32)
|
|
74
|
-
zeros_out = np.zeros(lead_out_samples, dtype=np.float32)
|
|
75
|
-
padded = np.concatenate([zeros_in, self.samples, zeros_out])
|
|
76
|
-
return AudioBuffer(padded, sample_rate=self.sample_rate)
|
|
77
|
-
|
|
78
|
-
@classmethod
|
|
79
|
-
def from_wav_file(cls, filepath: str) -> "AudioBuffer":
|
|
80
|
-
"""Load an AudioBuffer directly from a WAV file on disk."""
|
|
81
|
-
try:
|
|
82
|
-
with wave.open(filepath, "rb") as wf:
|
|
83
|
-
sr = wf.getframerate()
|
|
84
|
-
frames = wf.readframes(wf.getnframes())
|
|
85
|
-
pcm16 = np.frombuffer(frames, dtype=np.int16)
|
|
86
|
-
samples = (pcm16 / 32767.0).astype(np.float32)
|
|
87
|
-
return cls(samples, sample_rate=sr)
|
|
88
|
-
except Exception as e:
|
|
89
|
-
raise TTSAudioEncodingError(f"Failed to read WAV file '{filepath}': {e}") from e
|
|
90
|
-
|
|
91
|
-
def save(self, filepath: str) -> str:
|
|
92
|
-
"""Save the audio buffer to a WAV file on disk."""
|
|
93
|
-
target = Path(filepath).expanduser().resolve()
|
|
94
|
-
wav_data = self.to_wav_bytes()
|
|
95
|
-
try:
|
|
96
|
-
target.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
-
with open(target, "wb") as f:
|
|
98
|
-
f.write(wav_data)
|
|
99
|
-
return str(target)
|
|
100
|
-
except Exception as e:
|
|
101
|
-
raise TTSAudioEncodingError(f"Failed to save WAV to '{filepath}': {e}") from e
|
package/termux_tts/cli.py
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Command-Line Interface for termux-tts:
|
|
3
|
-
- termux-tts synth : Option A Deep Learning Neural Synthesis to File
|
|
4
|
-
- termux-tts speak : Option B Android System Native Immediate Voice Output
|
|
5
|
-
- termux-tts doctor: 12-Stage Vulkan GPU Hardware Diagnostics
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
import os
|
|
9
|
-
import sys
|
|
10
|
-
import argparse
|
|
11
|
-
from .engine import load, doctor
|
|
12
|
-
|
|
13
|
-
def main():
|
|
14
|
-
# Route to default subcommand 'synth' if no recognized subcommand is given
|
|
15
|
-
KNOWN_COMMANDS = {"synth", "speak", "doctor", "install", "component", "model", "instance"}
|
|
16
|
-
raw_args = sys.argv[1:]
|
|
17
|
-
if raw_args and raw_args[0] not in KNOWN_COMMANDS and raw_args[0] not in ("-h", "--help", "-v", "--version"):
|
|
18
|
-
sys.argv.insert(1, "synth")
|
|
19
|
-
|
|
20
|
-
parser = argparse.ArgumentParser(
|
|
21
|
-
prog="termux-tts",
|
|
22
|
-
description="Termux Neural & Native Text-to-Speech Engine"
|
|
23
|
-
)
|
|
24
|
-
subparsers = parser.add_subparsers(dest="command", help="Sub-commands")
|
|
25
|
-
|
|
26
|
-
# 1. Synth (4-Tier Speech Synthesis)
|
|
27
|
-
synth_parser = subparsers.add_parser("synth", help="Synthesize text to audio WAV file (Synth, Neural, Expressive)")
|
|
28
|
-
synth_parser.add_argument("text_pos", nargs="*", default=[], help="Input text to synthesize (positional)")
|
|
29
|
-
synth_parser.add_argument("-t", "--text", default=None, help="Input text to synthesize (flag)")
|
|
30
|
-
synth_parser.add_argument("-o", "--output", default="output.wav", help="Output WAV filepath")
|
|
31
|
-
synth_parser.add_argument("-l", "--lang", default="auto", help="Language code (auto=Multi-language auto switch, ko/kor=Korean only, en/eng=English only, ja/jpn=Japanese only)")
|
|
32
|
-
synth_parser.add_argument(
|
|
33
|
-
"-e", "--engine", default="auto",
|
|
34
|
-
choices=["auto", "vulkan", "ncnn", "gpu", "native", "neural", "onnx", "expressive", "multilingual", "hybrid", "kokoro", "melo", "supertonic"],
|
|
35
|
-
help="Synthesis engine tier (auto=Smart Routing, vulkan=GPU NCNN, native=Android voice, neural=VITS C++, kokoro=StyleTTS2 82M, melo=MeloTTS Bilingual, supertonic=Supertonic On-Device)"
|
|
36
|
-
)
|
|
37
|
-
synth_parser.add_argument("-m", "--model", default=None, help="Path to model file or directory")
|
|
38
|
-
synth_parser.add_argument("-p", "--preset", default="balanced", choices=["fast", "balanced", "expressive", "ultra"])
|
|
39
|
-
synth_parser.add_argument("-d", "--device", default="auto", choices=["auto", "gpu", "vulkan", "cpu"], help="Compute target device")
|
|
40
|
-
synth_parser.add_argument("-b", "--backend", dest="device", choices=["auto", "gpu", "vulkan", "cpu"], help="Alias for --device")
|
|
41
|
-
synth_parser.add_argument("--gpu", dest="device", action="store_const", const="gpu", help="Enable hardware GPU acceleration")
|
|
42
|
-
synth_parser.add_argument("--cpu", dest="device", action="store_const", const="cpu", help="Force CPU compute mode")
|
|
43
|
-
synth_parser.add_argument("--tier", default=None, choices=["high", "medium", "balanced", "fast", "ultra"], help="Target model tier (high=Studio FP16, medium=Balanced)")
|
|
44
|
-
synth_parser.add_argument("-s", "--speed", type=float, default=1.0, help="Speech speed multiplier (0.5 to 2.0)")
|
|
45
|
-
synth_parser.add_argument("--mode", default="unified", choices=["unified", "stitch"], help="Multilingual synthesis mode (unified=Single-pass G2P transliteration [BigTech Standard], stitch=Multi-model chunk concatenation)")
|
|
46
|
-
synth_parser.add_argument("--threads", type=int, default=4, help="Compute worker threads (ARM NEON)")
|
|
47
|
-
synth_parser.add_argument("--volume", type=int, default=None, help="Set Android media volume (1 to 15)")
|
|
48
|
-
synth_parser.add_argument("--play", action="store_true", help="Play synthesized audio through physical speaker immediately")
|
|
49
|
-
|
|
50
|
-
# 2. Speak (Option B: Native Samsung/Google System Voice)
|
|
51
|
-
speak_parser = subparsers.add_parser("speak", help="Speak text directly through device speaker (Option B: Native)")
|
|
52
|
-
speak_parser.add_argument("-t", "--text", required=True, help="Input text to speak")
|
|
53
|
-
speak_parser.add_argument("-l", "--lang", default="ko", help="Language code (ko, en)")
|
|
54
|
-
speak_parser.add_argument("-s", "--stream", default="MUSIC", help="Audio stream (MUSIC, NOTIFICATION, ALARM)")
|
|
55
|
-
speak_parser.add_argument("--volume", type=int, default=None, help="Set Android media volume (1 to 15)")
|
|
56
|
-
|
|
57
|
-
# 3. Doctor (Diagnostics)
|
|
58
|
-
subparsers.add_parser("doctor", help="Run 12-stage Vulkan GPU hardware diagnostics")
|
|
59
|
-
|
|
60
|
-
# 4. Install (One-Click Automated Provisioner)
|
|
61
|
-
install_parser = subparsers.add_parser("install", help="1-Click download and provision precompiled Vulkan binary & VITS studio models")
|
|
62
|
-
install_parser.add_argument("--tier", default="high", choices=["high", "medium"], help="Model resolution tier (high=57MB Studio FP16, medium=25MB Fast)")
|
|
63
|
-
install_parser.add_argument(
|
|
64
|
-
"--models", default="default",
|
|
65
|
-
help="Language model packages to provision: default (Korean & English only), or specific code: hi, ja, zh, ru, es, fr, de, or 'all'"
|
|
66
|
-
)
|
|
67
|
-
install_parser.add_argument("--force", action="store_true", help="Force overwrite existing binary and model assets")
|
|
68
|
-
install_parser.add_argument("--no-play", action="store_true", help="Skip playback verification during self-test")
|
|
69
|
-
|
|
70
|
-
# ── AMEVA Component Protocol v1 ─────────────────────────────────────────
|
|
71
|
-
_protocol_available = False
|
|
72
|
-
try:
|
|
73
|
-
from ameva_component.cli_support import build_protocol_subcommands
|
|
74
|
-
build_protocol_subcommands(subparsers)
|
|
75
|
-
_protocol_available = True
|
|
76
|
-
except ImportError as _proto_err:
|
|
77
|
-
_protocol_available = False
|
|
78
|
-
# ────────────────────────────────────────────────────────────────────────
|
|
79
|
-
|
|
80
|
-
args = parser.parse_args()
|
|
81
|
-
|
|
82
|
-
import subprocess
|
|
83
|
-
import shutil
|
|
84
|
-
|
|
85
|
-
if getattr(args, "volume", None) is not None:
|
|
86
|
-
if shutil.which("termux-volume"):
|
|
87
|
-
subprocess.run(["termux-volume", "music", str(args.volume)], check=False)
|
|
88
|
-
|
|
89
|
-
if args.command == "synth":
|
|
90
|
-
pos_text = " ".join(args.text_pos).strip() if getattr(args, "text_pos", None) else None
|
|
91
|
-
target_text = args.text or (pos_text if pos_text else None)
|
|
92
|
-
if not target_text:
|
|
93
|
-
synth_parser.error("the following arguments are required: text (as positional arguments or -t/--text)")
|
|
94
|
-
|
|
95
|
-
out_path = os.path.expanduser(args.output) if args.output else None
|
|
96
|
-
with load(
|
|
97
|
-
model=args.model,
|
|
98
|
-
language=args.lang,
|
|
99
|
-
preset=args.preset,
|
|
100
|
-
device=args.device,
|
|
101
|
-
threads=args.threads,
|
|
102
|
-
engine=args.engine,
|
|
103
|
-
tier=getattr(args, "tier", None),
|
|
104
|
-
) as engine:
|
|
105
|
-
res = engine.synthesize(
|
|
106
|
-
target_text,
|
|
107
|
-
output=out_path,
|
|
108
|
-
speed=args.speed,
|
|
109
|
-
language=args.lang,
|
|
110
|
-
mode=getattr(args, "mode", "unified")
|
|
111
|
-
)
|
|
112
|
-
backend_name = getattr(res, "backend", "UNKNOWN")
|
|
113
|
-
model_name = getattr(res, "model_name", "model")
|
|
114
|
-
dur = getattr(res, "duration_sec", 0.0)
|
|
115
|
-
elapsed = getattr(res, "elapsed_ms", 0.0)
|
|
116
|
-
rtf = getattr(res, "rtf", 0.0)
|
|
117
|
-
print(f"[SUCCESS] Synthesized via {backend_name} ({model_name}) -> {out_path or args.output}")
|
|
118
|
-
print(f" Duration: {dur:.2f}s | Elapsed: {elapsed:.1f}ms | RTF: {rtf:.4f}x")
|
|
119
|
-
|
|
120
|
-
if args.play and out_path and os.path.exists(out_path):
|
|
121
|
-
if shutil.which("termux-media-player"):
|
|
122
|
-
subprocess.run(["termux-media-player", "play", out_path], check=False)
|
|
123
|
-
elif shutil.which("play-audio"):
|
|
124
|
-
subprocess.run(["play-audio", out_path], check=False)
|
|
125
|
-
|
|
126
|
-
elif args.command == "speak":
|
|
127
|
-
with load(language=args.lang) as engine:
|
|
128
|
-
res = engine.speak(args.text, stream=args.stream)
|
|
129
|
-
print(f"[SUCCESS] Spoken via {res.engine_name} on stream {args.stream} ({res.elapsed_ms:.1f}ms)")
|
|
130
|
-
|
|
131
|
-
elif args.command == "doctor":
|
|
132
|
-
diag = doctor()
|
|
133
|
-
print("=" * 60)
|
|
134
|
-
print(" TERMUX-TTS 12-STAGE VULKAN HARDWARE DIAGNOSTICS")
|
|
135
|
-
print("=" * 60)
|
|
136
|
-
for k, v in diag.items():
|
|
137
|
-
print(f" - {k:30s}: {v}")
|
|
138
|
-
print("=" * 60)
|
|
139
|
-
|
|
140
|
-
elif args.command == "install":
|
|
141
|
-
from .installer import run_installation
|
|
142
|
-
run_installation(tier=args.tier, models=getattr(args, "models", "all"), force=args.force, play=not args.no_play)
|
|
143
|
-
|
|
144
|
-
elif args.command in ("component", "model", "instance") and _protocol_available:
|
|
145
|
-
from ameva_component.cli_support import dispatch_protocol
|
|
146
|
-
from termux_tts.control import TTSControl
|
|
147
|
-
dispatch_protocol(args, TTSControl())
|
|
148
|
-
elif args.command in ("component", "model", "instance"):
|
|
149
|
-
print("[ERROR] ameva-component-sdk not installed.", file=sys.stderr)
|
|
150
|
-
sys.exit(1)
|
|
151
|
-
else:
|
|
152
|
-
parser.print_help()
|
|
153
|
-
|
|
154
|
-
if __name__ == "__main__":
|
|
155
|
-
main()
|
|
156
|
-
|