termux-tts 0.1.1 → 1.3.0
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/CHANGELOG.md +18 -0
- package/README.md +91 -8
- package/README.pypi.md +25 -18
- package/doc.config.yaml +517 -83
- package/docs/guide.md +5 -5
- package/docs/tts_guide.md +5 -5
- package/install.sh +3 -3
- package/package.json +2 -2
- package/pyproject.toml +3 -2
- package/setup.py +3 -3
- package/termux_tts/__init__.py +63 -47
- package/termux_tts/adapter.py +139 -36
- package/termux_tts/audio.py +24 -1
- package/termux_tts/cli.py +127 -82
- package/termux_tts/control/component.py +166 -11
- package/termux_tts/engine.py +133 -40
- package/termux_tts/engine_dsp.py +332 -332
- package/termux_tts/engine_expressive.py +216 -0
- package/termux_tts/engine_onnx.py +5 -195
- package/termux_tts/engine_sherpa.py +248 -0
- package/termux_tts/engine_vulkan.py +210 -0
- package/termux_tts/installer.py +158 -0
- package/termux_tts/vulkan_probe.py +32 -8
- package/dsp_test.wav +0 -0
- package/expressive_demo.wav +0 -0
- package/g2p_test.wav +0 -0
- package/test_cli.wav +0 -0
- package/tests/test_expressive_presets.py +0 -29
- package/tests/test_g2p_korean.py +0 -73
- package/tests/test_granular_tts.py +0 -141
- package/tests/test_native_engine.py +0 -38
- package/tests/test_onnx_engine.py +0 -48
- package/tests/test_vulkan_routing.py +0 -52
package/termux_tts/cli.py
CHANGED
|
@@ -1,82 +1,127 @@
|
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
synth_parser.
|
|
22
|
-
synth_parser.add_argument("-
|
|
23
|
-
synth_parser.add_argument("-
|
|
24
|
-
synth_parser.add_argument("-
|
|
25
|
-
synth_parser.add_argument(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="termux-tts",
|
|
16
|
+
description="Termux Neural & Native Text-to-Speech Engine"
|
|
17
|
+
)
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command", help="Sub-commands")
|
|
19
|
+
|
|
20
|
+
# 1. Synth (4-Tier Speech Synthesis)
|
|
21
|
+
synth_parser = subparsers.add_parser("synth", help="Synthesize text to audio WAV file (Synth, Neural, Expressive)")
|
|
22
|
+
synth_parser.add_argument("-t", "--text", required=True, help="Input text to synthesize")
|
|
23
|
+
synth_parser.add_argument("-o", "--output", default="output.wav", help="Output WAV filepath")
|
|
24
|
+
synth_parser.add_argument("-l", "--lang", default="ko", help="Language code (ko, en)")
|
|
25
|
+
synth_parser.add_argument(
|
|
26
|
+
"-e", "--engine", default="auto",
|
|
27
|
+
choices=["auto", "vulkan", "ncnn", "gpu", "synth", "dsp", "native", "neural", "onnx", "expressive"],
|
|
28
|
+
help="Synthesis engine tier (vulkan=GPU NCNN, synth=0MB DSP, native=Android voice, neural=VITS C++, expressive=emotional)"
|
|
29
|
+
)
|
|
30
|
+
synth_parser.add_argument("-m", "--model", default=None, help="Path to model file or directory")
|
|
31
|
+
synth_parser.add_argument("-p", "--preset", default="balanced", choices=["fast", "balanced", "expressive", "ultra"])
|
|
32
|
+
synth_parser.add_argument("-d", "--device", default="auto", choices=["auto", "gpu", "vulkan", "cpu"])
|
|
33
|
+
synth_parser.add_argument("-s", "--speed", type=float, default=1.0, help="Speech speed multiplier (0.5 to 2.0)")
|
|
34
|
+
synth_parser.add_argument("--threads", type=int, default=4, help="Compute worker threads (ARM NEON)")
|
|
35
|
+
synth_parser.add_argument("--volume", type=int, default=None, help="Set Android media volume (1 to 15)")
|
|
36
|
+
synth_parser.add_argument("--play", action="store_true", help="Play synthesized audio through physical speaker immediately")
|
|
37
|
+
|
|
38
|
+
# 2. Speak (Option B: Native Samsung/Google System Voice)
|
|
39
|
+
speak_parser = subparsers.add_parser("speak", help="Speak text directly through device speaker (Option B: Native)")
|
|
40
|
+
speak_parser.add_argument("-t", "--text", required=True, help="Input text to speak")
|
|
41
|
+
speak_parser.add_argument("-l", "--lang", default="ko", help="Language code (ko, en)")
|
|
42
|
+
speak_parser.add_argument("-s", "--stream", default="MUSIC", help="Audio stream (MUSIC, NOTIFICATION, ALARM)")
|
|
43
|
+
speak_parser.add_argument("--volume", type=int, default=None, help="Set Android media volume (1 to 15)")
|
|
44
|
+
|
|
45
|
+
# 3. Doctor (Diagnostics)
|
|
46
|
+
subparsers.add_parser("doctor", help="Run 12-stage Vulkan GPU hardware diagnostics")
|
|
47
|
+
|
|
48
|
+
# 4. Install (One-Click Automated Provisioner)
|
|
49
|
+
install_parser = subparsers.add_parser("install", help="1-Click download and provision precompiled Vulkan binary & VITS studio models")
|
|
50
|
+
install_parser.add_argument("--tier", default="high", choices=["high", "medium"], help="Model resolution tier (high=57MB Studio FP16, medium=25MB Fast)")
|
|
51
|
+
install_parser.add_argument("--force", action="store_true", help="Force overwrite existing binary and model assets")
|
|
52
|
+
install_parser.add_argument("--no-play", action="store_true", help="Skip playback verification during self-test")
|
|
53
|
+
|
|
54
|
+
# ── AMEVA Component Protocol v1 ─────────────────────────────────────────
|
|
55
|
+
_protocol_available = False
|
|
56
|
+
try:
|
|
57
|
+
from ameva_component.cli_support import build_protocol_subcommands
|
|
58
|
+
build_protocol_subcommands(subparsers)
|
|
59
|
+
_protocol_available = True
|
|
60
|
+
except ImportError as _proto_err:
|
|
61
|
+
_protocol_available = False
|
|
62
|
+
# ────────────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
args = parser.parse_args()
|
|
65
|
+
|
|
66
|
+
import subprocess
|
|
67
|
+
import shutil
|
|
68
|
+
|
|
69
|
+
if getattr(args, "volume", None) is not None:
|
|
70
|
+
if shutil.which("termux-volume"):
|
|
71
|
+
subprocess.run(["termux-volume", "music", str(args.volume)], check=False)
|
|
72
|
+
|
|
73
|
+
if args.command == "synth":
|
|
74
|
+
with load(
|
|
75
|
+
model=args.model,
|
|
76
|
+
language=args.lang,
|
|
77
|
+
preset=args.preset,
|
|
78
|
+
device=args.device,
|
|
79
|
+
threads=args.threads,
|
|
80
|
+
engine=args.engine,
|
|
81
|
+
) as engine:
|
|
82
|
+
res = engine.synthesize(args.text, output=args.output, speed=args.speed)
|
|
83
|
+
backend_name = getattr(res, "backend", "UNKNOWN")
|
|
84
|
+
model_name = getattr(res, "model_name", "model")
|
|
85
|
+
dur = getattr(res, "duration_sec", 0.0)
|
|
86
|
+
elapsed = getattr(res, "elapsed_ms", 0.0)
|
|
87
|
+
rtf = getattr(res, "rtf", 0.0)
|
|
88
|
+
print(f"[SUCCESS] Synthesized via {backend_name} ({model_name}) -> {args.output}")
|
|
89
|
+
print(f" Duration: {dur:.2f}s | Elapsed: {elapsed:.1f}ms | RTF: {rtf:.4f}x")
|
|
90
|
+
|
|
91
|
+
if args.play and args.output and os.path.exists(args.output):
|
|
92
|
+
if shutil.which("termux-media-player"):
|
|
93
|
+
subprocess.run(["termux-media-player", "play", args.output], check=False)
|
|
94
|
+
elif shutil.which("play-audio"):
|
|
95
|
+
subprocess.run(["play-audio", args.output], check=False)
|
|
96
|
+
|
|
97
|
+
elif args.command == "speak":
|
|
98
|
+
with load(language=args.lang) as engine:
|
|
99
|
+
res = engine.speak(args.text, stream=args.stream)
|
|
100
|
+
print(f"[SUCCESS] Spoken via {res.engine_name} on stream {args.stream} ({res.elapsed_ms:.1f}ms)")
|
|
101
|
+
|
|
102
|
+
elif args.command == "doctor":
|
|
103
|
+
diag = doctor()
|
|
104
|
+
print("=" * 60)
|
|
105
|
+
print(" TERMUX-TTS 12-STAGE VULKAN HARDWARE DIAGNOSTICS")
|
|
106
|
+
print("=" * 60)
|
|
107
|
+
for k, v in diag.items():
|
|
108
|
+
print(f" - {k:30s}: {v}")
|
|
109
|
+
print("=" * 60)
|
|
110
|
+
|
|
111
|
+
elif args.command == "install":
|
|
112
|
+
from .installer import run_installation
|
|
113
|
+
run_installation(tier=args.tier, force=args.force, play=not args.no_play)
|
|
114
|
+
|
|
115
|
+
elif args.command in ("component", "model", "instance") and _protocol_available:
|
|
116
|
+
from ameva_component.cli_support import dispatch_protocol
|
|
117
|
+
from termux_tts.control import TTSControl
|
|
118
|
+
dispatch_protocol(args, TTSControl())
|
|
119
|
+
elif args.command in ("component", "model", "instance"):
|
|
120
|
+
print("[ERROR] ameva-component-sdk not installed.", file=sys.stderr)
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
else:
|
|
123
|
+
parser.print_help()
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
main()
|
|
127
|
+
|
|
@@ -68,7 +68,9 @@ class TTSControl(ComponentControl):
|
|
|
68
68
|
ts = now_timestamps()
|
|
69
69
|
state_data = self._state_file.read()
|
|
70
70
|
stale = self._state_file.is_stale(threshold_ms=30_000)
|
|
71
|
-
|
|
71
|
+
pid_info = self._check_pid()
|
|
72
|
+
pid = pid_info.get("pid")
|
|
73
|
+
pid_alive = pid_info.get("alive")
|
|
72
74
|
instances = self._inst_reg.list_all()
|
|
73
75
|
hot = [i for i in instances if i.state == InstanceState.HOT]
|
|
74
76
|
|
|
@@ -78,11 +80,22 @@ class TTSControl(ComponentControl):
|
|
|
78
80
|
try:
|
|
79
81
|
from termux_tts.engine_onnx import ONNXNeuralEngine
|
|
80
82
|
onnx_available = True
|
|
81
|
-
except ImportError:
|
|
82
|
-
|
|
83
|
+
except ImportError as _imp_err:
|
|
84
|
+
import logging
|
|
85
|
+
logging.getLogger(__name__).debug("[tts] ONNXNeuralEngine not importable: %s", _imp_err)
|
|
83
86
|
|
|
84
87
|
ready = dsp_available # DSP는 Zero Dependency이므로 항상 최소 가용
|
|
85
|
-
degraded = stale or not
|
|
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"]
|
|
86
99
|
|
|
87
100
|
return {
|
|
88
101
|
"protocol": "ameva-component-status/1",
|
|
@@ -92,7 +105,7 @@ class TTSControl(ComponentControl):
|
|
|
92
105
|
"ready": ready,
|
|
93
106
|
"degraded": degraded,
|
|
94
107
|
**ts,
|
|
95
|
-
"process":
|
|
108
|
+
"process": proc_dict,
|
|
96
109
|
"capabilities": list(self.CAPABILITIES),
|
|
97
110
|
"active_models": [i.model_id for i in hot],
|
|
98
111
|
"backends": {
|
|
@@ -108,15 +121,104 @@ class TTSControl(ComponentControl):
|
|
|
108
121
|
},
|
|
109
122
|
}
|
|
110
123
|
|
|
111
|
-
def _check_pid(self) ->
|
|
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
|
+
|
|
112
130
|
if self.DEFAULT_PID_FILE.exists():
|
|
113
131
|
try:
|
|
114
|
-
|
|
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:
|
|
115
147
|
os.kill(pid, 0)
|
|
116
|
-
return pid, True
|
|
117
|
-
except
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
}
|
|
120
222
|
|
|
121
223
|
def doctor_full(self) -> dict:
|
|
122
224
|
"""기존 TTSEngine doctor() 호출 — 12단계 Vulkan Doctor 포함."""
|
|
@@ -240,3 +342,56 @@ class TTSControl(ComponentControl):
|
|
|
240
342
|
"ready": _ready, "degraded": not _ready, **ts,
|
|
241
343
|
"active_models": [i.model_id for i in hot], "last_error": last_error,
|
|
242
344
|
})
|
|
345
|
+
|
|
346
|
+
async def synthesize(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
347
|
+
"""AMEVA Component Protocol v1 synthesize method."""
|
|
348
|
+
text = request.get("text", "").strip()
|
|
349
|
+
if not text:
|
|
350
|
+
return {
|
|
351
|
+
"ok": False,
|
|
352
|
+
"error": {
|
|
353
|
+
"code": "TEXT_EMPTY",
|
|
354
|
+
"message": "Input text cannot be empty",
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
voice_id = request.get("voice_id")
|
|
359
|
+
model_id = request.get("model_id")
|
|
360
|
+
output_path = request.get("output_path")
|
|
361
|
+
engine_type = request.get("engine", "auto")
|
|
362
|
+
device = request.get("device", "auto")
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
from termux_tts.engine import TTSEngine
|
|
366
|
+
model_file = str(self._models_dir / model_id) if model_id else None
|
|
367
|
+
engine = TTSEngine(
|
|
368
|
+
model_path=model_file,
|
|
369
|
+
engine_type=engine_type,
|
|
370
|
+
device=device,
|
|
371
|
+
)
|
|
372
|
+
res = engine.synthesize(text, output=output_path)
|
|
373
|
+
|
|
374
|
+
out_file = output_path
|
|
375
|
+
if not out_file and hasattr(res, "save"):
|
|
376
|
+
import tempfile
|
|
377
|
+
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
|
378
|
+
out_file = f.name
|
|
379
|
+
res.save(out_file)
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
"ok": True,
|
|
383
|
+
"audio_path": out_file or getattr(res, "output_path", None),
|
|
384
|
+
"duration_sec": getattr(res, "duration_sec", 0.0),
|
|
385
|
+
"sample_rate": getattr(res, "sample_rate", 22050),
|
|
386
|
+
"model_id": getattr(res, "model_name", model_id or "default"),
|
|
387
|
+
"backend": getattr(res, "backend", "unknown"),
|
|
388
|
+
"fallback_used": False,
|
|
389
|
+
}
|
|
390
|
+
except Exception as e:
|
|
391
|
+
return {
|
|
392
|
+
"ok": False,
|
|
393
|
+
"error": {
|
|
394
|
+
"code": "SYNTHESIZE_FAILED",
|
|
395
|
+
"message": str(e),
|
|
396
|
+
}
|
|
397
|
+
}
|