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/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 argparse
9
- from .engine import load, doctor
10
-
11
- def main():
12
- parser = argparse.ArgumentParser(
13
- prog="termux-tts",
14
- description="Termux Neural & Native Text-to-Speech Engine"
15
- )
16
- subparsers = parser.add_subparsers(dest="command", help="Sub-commands")
17
-
18
- # 1. Synth (Option A: DSP Formant or ONNX Neural Vocoder to File)
19
- synth_parser = subparsers.add_parser("synth", help="Synthesize text to audio WAV file (DSP / ONNX)")
20
- synth_parser.add_argument("-t", "--text", required=True, help="Input text to synthesize")
21
- synth_parser.add_argument("-o", "--output", default="output.wav", help="Output WAV filepath")
22
- synth_parser.add_argument("-l", "--lang", default="ko", help="Language code (ko, en)")
23
- synth_parser.add_argument("-e", "--engine", default="auto", choices=["auto", "dsp", "onnx"], help="Synthesis engine (dsp=zero-dependency, onnx=deep learning)")
24
- synth_parser.add_argument("-m", "--model", default=None, help="Path to .onnx model file (required for onnx engine)")
25
- synth_parser.add_argument("-p", "--preset", default="balanced", choices=["fast", "balanced", "expressive", "ultra"])
26
- synth_parser.add_argument("-d", "--device", default="auto", choices=["auto", "gpu", "vulkan", "cpu"])
27
- synth_parser.add_argument("-s", "--speed", type=float, default=1.0, help="Speech speed multiplier (0.5 to 2.0)")
28
-
29
- # 2. Speak (Option B: Native Samsung/Google System Voice)
30
- speak_parser = subparsers.add_parser("speak", help="Speak text directly through device speaker (Option B: Native)")
31
- speak_parser.add_argument("-t", "--text", required=True, help="Input text to speak")
32
- speak_parser.add_argument("-l", "--lang", default="ko", help="Language code (ko, en)")
33
- speak_parser.add_argument("-s", "--stream", default="MUSIC", help="Audio stream (MUSIC, NOTIFICATION, ALARM)")
34
-
35
- # 3. Doctor (Diagnostics)
36
- subparsers.add_parser("doctor", help="Run 12-stage Vulkan GPU hardware diagnostics")
37
-
38
- # ── AMEVA Component Protocol v1 ─────────────────────────────────────────
39
- _protocol_available = False
40
- try:
41
- from ameva_component.cli_support import build_protocol_subcommands
42
- build_protocol_subcommands(subparsers)
43
- _protocol_available = True
44
- except ImportError:
45
- pass
46
- # ────────────────────────────────────────────────────────────────────────
47
-
48
- args = parser.parse_args()
49
-
50
- if args.command == "synth":
51
- with load(model=args.model, language=args.lang, preset=args.preset, device=args.device, engine=args.engine) as engine:
52
- res = engine.synthesize(args.text, output=args.output, speed=args.speed)
53
- print(f"[SUCCESS] Synthesized via {res.backend} ({res.model_name}) -> {args.output}")
54
- print(f" Duration: {res.duration_sec:.2f}s | Elapsed: {res.elapsed_ms:.1f}ms | RTF: {res.rtf:.4f}x")
55
-
56
- elif args.command == "speak":
57
- with load(language=args.lang) as engine:
58
- res = engine.speak(args.text, stream=args.stream)
59
- print(f"[SUCCESS] Spoken via {res.engine_name} on stream {args.stream} ({res.elapsed_ms:.1f}ms)")
60
-
61
- elif args.command == "doctor":
62
- diag = doctor()
63
- print("=" * 60)
64
- print(" TERMUX-TTS 12-STAGE VULKAN HARDWARE DIAGNOSTICS")
65
- print("=" * 60)
66
- for k, v in diag.items():
67
- print(f" - {k:30s}: {v}")
68
- print("=" * 60)
69
-
70
- elif args.command in ("component", "model", "instance") and _protocol_available:
71
- from ameva_component.cli_support import dispatch_protocol
72
- from termux_tts.control import TTSControl
73
- dispatch_protocol(args, TTSControl())
74
- elif args.command in ("component", "model", "instance"):
75
- print("[ERROR] ameva-component-sdk not installed.", file=sys.stderr)
76
- sys.exit(1)
77
- else:
78
- parser.print_help()
79
-
80
- if __name__ == "__main__":
81
- main()
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
- pid, pid_alive = self._check_pid()
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
- pass
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 pid_alive
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": {"running": pid_alive, "pid": pid},
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) -> tuple[int | None, bool]:
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
- pid = int(self.DEFAULT_PID_FILE.read_text().strip())
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 Exception:
118
- pass
119
- return None, False
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
+ }