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.
- package/LICENSE +17 -0
- package/README.md +31 -0
- package/README.pypi.md +47 -0
- package/bin/cli.js +33 -0
- package/binding_node/index.js +117 -0
- package/binding_node/test_node.js +40 -0
- package/doc.config.yaml +98 -0
- package/docs/benchmarks.md +12 -0
- package/docs/guide.md +552 -0
- package/docs/tts_guide.md +552 -0
- package/dsp_test.wav +0 -0
- package/expressive_demo.wav +0 -0
- package/g2p_test.wav +0 -0
- package/index.js +5 -0
- package/install.sh +53 -0
- package/package.json +31 -0
- package/pyproject.toml +43 -0
- package/setup.py +38 -0
- package/termux_tts/__init__.py +47 -0
- package/termux_tts/adapter.py +99 -0
- package/termux_tts/audio.py +75 -0
- package/termux_tts/cli.py +82 -0
- package/termux_tts/control/__init__.py +4 -0
- package/termux_tts/control/component.py +242 -0
- package/termux_tts/control/errors.py +52 -0
- package/termux_tts/control/instances.py +137 -0
- package/termux_tts/control/models.py +151 -0
- package/termux_tts/control/status.py +13 -0
- package/termux_tts/engine.py +157 -0
- package/termux_tts/engine_dsp.py +332 -0
- package/termux_tts/engine_native.py +104 -0
- package/termux_tts/engine_onnx.py +199 -0
- package/termux_tts/exceptions.py +28 -0
- package/termux_tts/g2p_korean.py +242 -0
- package/termux_tts/tokenizer.py +184 -0
- package/termux_tts/vulkan_probe.py +25 -0
- package/test_cli.wav +0 -0
- package/tests/test_expressive_presets.py +29 -0
- package/tests/test_g2p_korean.py +73 -0
- package/tests/test_granular_tts.py +141 -0
- package/tests/test_native_engine.py +38 -0
- package/tests/test_onnx_engine.py +48 -0
- package/tests/test_vulkan_routing.py +52 -0
package/pyproject.toml
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "termux-tts"
|
|
7
|
+
version = "1.1.3"
|
|
8
|
+
description = "On-device Text-to-Speech framework utilizing device resources (DSP Formant Vocoder, ONNX Neural Runtime & Android Native Voice Bridge)"
|
|
9
|
+
readme = "README.pypi.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "AOSF / uno-km", email = "zhfldk014745@naver.com" }
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: Apache Software License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Multimedia :: Sound/Audio :: Speech",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"numpy>=1.20.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
onnx = ["onnxruntime>=1.15.0"]
|
|
32
|
+
neural = ["onnxruntime>=1.15.0"]
|
|
33
|
+
dev = ["pytest>=7.0", "paramiko>=3.0"]
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
termux-tts = "termux_tts.cli:main"
|
|
37
|
+
|
|
38
|
+
[project.entry-points."ameva.components"]
|
|
39
|
+
termux-tts = "termux_tts.adapter:create_adapter"
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.packages.find]
|
|
42
|
+
where = ["."]
|
|
43
|
+
include = ["termux_tts*"]
|
package/setup.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from setuptools import setup, find_packages
|
|
3
|
+
|
|
4
|
+
setup(
|
|
5
|
+
name="termux-tts",
|
|
6
|
+
version="1.1.3",
|
|
7
|
+
description="Ultra-Fast On-Device Dual-Engine Text-to-Speech Framework (Parametric Formant Acoustic Synthesizer & Android Native Voice Bridge)",
|
|
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-vulkan-runtime>=1.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
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
termux-tts: Production-Grade Dual-Engine TTS Framework for Android Termux.
|
|
3
|
+
- Option A: Deep Learning VITS ONNX Neural Vocoder (Vulkan GPU Accelerated)
|
|
4
|
+
- Option B: Android System Native Voice Engine Bridge (Samsung / Google Voice)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .engine import TTSEngine, load, doctor
|
|
8
|
+
from .engine_native import NativeAndroidEngine, NativeResult
|
|
9
|
+
from .engine_dsp import ParametricDSPEngine, DSPResult, QUALITY_PRESETS, DSPSynthesizer
|
|
10
|
+
from .engine_onnx import ONNXNeuralEngine, ONNXResult
|
|
11
|
+
from .tokenizer import PhoneticTokenizer, EXPRESSIVE_TAGS
|
|
12
|
+
from .g2p_korean import KoreanG2PEngine, korean_text_to_phonemes
|
|
13
|
+
from .audio import AudioBuffer
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
TTSError,
|
|
16
|
+
TTSModelLoadError,
|
|
17
|
+
TTSInferenceError,
|
|
18
|
+
VulkanInitializationError,
|
|
19
|
+
TTSAudioEncodingError,
|
|
20
|
+
TTSLanguageNotSupportedError
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__version__ = "1.1.2"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"TTSEngine",
|
|
26
|
+
"load",
|
|
27
|
+
"doctor",
|
|
28
|
+
"ParametricDSPEngine",
|
|
29
|
+
"DSPSynthesizer",
|
|
30
|
+
"DSPResult",
|
|
31
|
+
"NativeAndroidEngine",
|
|
32
|
+
"NativeResult",
|
|
33
|
+
"ONNXNeuralEngine",
|
|
34
|
+
"ONNXResult",
|
|
35
|
+
"QUALITY_PRESETS",
|
|
36
|
+
"PhoneticTokenizer",
|
|
37
|
+
"KoreanG2PEngine",
|
|
38
|
+
"korean_text_to_phonemes",
|
|
39
|
+
"EXPRESSIVE_TAGS",
|
|
40
|
+
"AudioBuffer",
|
|
41
|
+
"TTSError",
|
|
42
|
+
"TTSModelLoadError",
|
|
43
|
+
"TTSInferenceError",
|
|
44
|
+
"VulkanInitializationError",
|
|
45
|
+
"TTSAudioEncodingError",
|
|
46
|
+
"TTSLanguageNotSupportedError"
|
|
47
|
+
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
termux_tts.adapter
|
|
3
|
+
===================
|
|
4
|
+
AMEVA Component Protocol v1 — Orchestrator Adapter (v0.8.1 호환)
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, AsyncIterator
|
|
9
|
+
|
|
10
|
+
from ameva_component.adapter_base import BaseOrchestratorAdapter
|
|
11
|
+
from termux_tts.control.component import TTSControl
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TTSOrchestratorAdapter(BaseOrchestratorAdapter):
|
|
15
|
+
"""TTS (Text-to-Speech) Orchestrator Adapter.
|
|
16
|
+
|
|
17
|
+
합성은 파일 기반으로 수행됩니다.
|
|
18
|
+
infer()는 text를 받아 audio 파일 경로를 반환합니다.
|
|
19
|
+
요청 모델과 실행 모델이 다르면 fallback_used=True를 명시합니다.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
COMPONENT_ID = "termux-tts"
|
|
23
|
+
|
|
24
|
+
def __init__(self, control: TTSControl | None = None) -> None:
|
|
25
|
+
self._control = control or TTSControl()
|
|
26
|
+
|
|
27
|
+
async def infer(self, request: dict[str, Any]) -> AsyncIterator[dict[str, Any]]:
|
|
28
|
+
"""TTS synthesis: text → audio file path.
|
|
29
|
+
|
|
30
|
+
request 키:
|
|
31
|
+
text (str): 합성할 텍스트 (필수)
|
|
32
|
+
voice_id (str): 목소리 ID (선택)
|
|
33
|
+
model_id (str): 모델 ID (선택)
|
|
34
|
+
output_path (str): 출력 파일 경로 (선택, 없으면 임시 파일)
|
|
35
|
+
|
|
36
|
+
반환 프레임:
|
|
37
|
+
{"type": "audio", "audio_path": str, "final": bool, "fallback_used": bool}
|
|
38
|
+
{"type": "error", "code": str, "message": str}
|
|
39
|
+
"""
|
|
40
|
+
text = request.get("text", "").strip()
|
|
41
|
+
if not text:
|
|
42
|
+
yield {
|
|
43
|
+
"type": "error",
|
|
44
|
+
"ok": False,
|
|
45
|
+
"error": {
|
|
46
|
+
"code": "TEXT_EMPTY",
|
|
47
|
+
"message": "text is required and must not be empty",
|
|
48
|
+
"operation": "infer",
|
|
49
|
+
"component_id": self.COMPONENT_ID,
|
|
50
|
+
"retryable": False,
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
if hasattr(self._control, "synthesize"):
|
|
56
|
+
try:
|
|
57
|
+
result = await self._control.synthesize(request)
|
|
58
|
+
audio_path = result.get("audio_path", "")
|
|
59
|
+
if not audio_path:
|
|
60
|
+
yield {
|
|
61
|
+
"type": "error",
|
|
62
|
+
"ok": False,
|
|
63
|
+
"error": {
|
|
64
|
+
"code": "SYNTHESIS_FAILED",
|
|
65
|
+
"message": "synthesize() returned no audio_path",
|
|
66
|
+
"operation": "infer",
|
|
67
|
+
"component_id": self.COMPONENT_ID,
|
|
68
|
+
"retryable": True,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
return
|
|
72
|
+
yield {
|
|
73
|
+
"type": "audio",
|
|
74
|
+
"audio_path": audio_path,
|
|
75
|
+
"final": True,
|
|
76
|
+
"fallback_used": result.get("fallback_used", False),
|
|
77
|
+
"requested_voice": result.get("requested_voice"),
|
|
78
|
+
"executed_voice": result.get("executed_voice"),
|
|
79
|
+
"ok": True,
|
|
80
|
+
}
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
yield {
|
|
83
|
+
"type": "error",
|
|
84
|
+
"ok": False,
|
|
85
|
+
"error": {
|
|
86
|
+
"code": "SYNTHESIS_FAILED",
|
|
87
|
+
"message": str(exc),
|
|
88
|
+
"operation": "infer",
|
|
89
|
+
"component_id": self.COMPONENT_ID,
|
|
90
|
+
"retryable": True,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
else:
|
|
94
|
+
yield self._not_supported("infer.synthesize")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def create_adapter() -> TTSOrchestratorAdapter:
|
|
98
|
+
"""Entry Point Factory."""
|
|
99
|
+
return TTSOrchestratorAdapter()
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
import numpy as np
|
|
9
|
+
from typing import Union
|
|
10
|
+
from .exceptions import TTSAudioEncodingError
|
|
11
|
+
|
|
12
|
+
class AudioBuffer:
|
|
13
|
+
"""Manages raw floating-point audio samples and converts to 16-bit PCM WAV."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, samples: Union[np.ndarray, list], sample_rate: int = 22050):
|
|
16
|
+
if isinstance(samples, list):
|
|
17
|
+
self.samples = np.array(samples, dtype=np.float32)
|
|
18
|
+
elif isinstance(samples, np.ndarray):
|
|
19
|
+
self.samples = samples.astype(np.float32).flatten()
|
|
20
|
+
else:
|
|
21
|
+
raise TTSAudioEncodingError("Samples must be a list or numpy ndarray.")
|
|
22
|
+
|
|
23
|
+
self.sample_rate = sample_rate
|
|
24
|
+
self._normalize_and_clip()
|
|
25
|
+
|
|
26
|
+
def _normalize_and_clip(self) -> None:
|
|
27
|
+
"""Apply peak normalization and soft clipping to prevent distortion."""
|
|
28
|
+
if len(self.samples) == 0:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
peak = np.max(np.abs(self.samples))
|
|
32
|
+
if peak > 1.0:
|
|
33
|
+
self.samples = self.samples / peak
|
|
34
|
+
elif peak < 0.0001:
|
|
35
|
+
pass # Keep quiet signals as is
|
|
36
|
+
else:
|
|
37
|
+
# Gentle scale to target -1dB
|
|
38
|
+
self.samples = self.samples * 0.95
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def duration_seconds(self) -> float:
|
|
42
|
+
"""Duration of the audio in seconds."""
|
|
43
|
+
if self.sample_rate <= 0:
|
|
44
|
+
return 0.0
|
|
45
|
+
return len(self.samples) / float(self.sample_rate)
|
|
46
|
+
|
|
47
|
+
def to_pcm16_bytes(self) -> bytes:
|
|
48
|
+
"""Convert float32 [-1.0, 1.0] samples into 16-bit signed integer bytes."""
|
|
49
|
+
clipped = np.clip(self.samples, -1.0, 1.0)
|
|
50
|
+
pcm16 = (clipped * 32767.0).astype(np.int16)
|
|
51
|
+
return pcm16.tobytes()
|
|
52
|
+
|
|
53
|
+
def to_wav_bytes(self) -> bytes:
|
|
54
|
+
"""Encode raw samples into standard RIFF WAV format."""
|
|
55
|
+
try:
|
|
56
|
+
pcm_bytes = self.to_pcm16_bytes()
|
|
57
|
+
buf = io.BytesIO()
|
|
58
|
+
with wave.open(buf, "wb") as wav_file:
|
|
59
|
+
wav_file.setnchannels(1) # Mono
|
|
60
|
+
wav_file.setsampwidth(2) # 16-bit (2 bytes)
|
|
61
|
+
wav_file.setframerate(self.sample_rate)
|
|
62
|
+
wav_file.writeframes(pcm_bytes)
|
|
63
|
+
return buf.getvalue()
|
|
64
|
+
except Exception as e:
|
|
65
|
+
raise TTSAudioEncodingError(f"Failed to encode WAV buffer: {e}") from e
|
|
66
|
+
|
|
67
|
+
def save(self, filepath: str) -> str:
|
|
68
|
+
"""Save the audio buffer to a WAV file on disk."""
|
|
69
|
+
wav_data = self.to_wav_bytes()
|
|
70
|
+
try:
|
|
71
|
+
with open(filepath, "wb") as f:
|
|
72
|
+
f.write(wav_data)
|
|
73
|
+
return filepath
|
|
74
|
+
except Exception as e:
|
|
75
|
+
raise TTSAudioEncodingError(f"Failed to save WAV to '{filepath}': {e}") from e
|
|
@@ -0,0 +1,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 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
|
+
|
|
@@ -0,0 +1,242 @@
|
|
|
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, pid_alive = self._check_pid()
|
|
72
|
+
instances = self._inst_reg.list_all()
|
|
73
|
+
hot = [i for i in instances if i.state == InstanceState.HOT]
|
|
74
|
+
|
|
75
|
+
# DSP 백엔드는 항상 가용 (Zero Dependency)
|
|
76
|
+
dsp_available = True
|
|
77
|
+
onnx_available = False
|
|
78
|
+
try:
|
|
79
|
+
from termux_tts.engine_onnx import ONNXNeuralEngine
|
|
80
|
+
onnx_available = True
|
|
81
|
+
except ImportError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
ready = dsp_available # DSP는 Zero Dependency이므로 항상 최소 가용
|
|
85
|
+
degraded = stale or not pid_alive
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
"protocol": "ameva-component-status/1",
|
|
89
|
+
"component_id": self.COMPONENT_ID,
|
|
90
|
+
"component_type": self.COMPONENT_TYPE,
|
|
91
|
+
"version": self._get_version(),
|
|
92
|
+
"ready": ready,
|
|
93
|
+
"degraded": degraded,
|
|
94
|
+
**ts,
|
|
95
|
+
"process": {"running": pid_alive, "pid": pid},
|
|
96
|
+
"capabilities": list(self.CAPABILITIES),
|
|
97
|
+
"active_models": [i.model_id for i in hot],
|
|
98
|
+
"backends": {
|
|
99
|
+
"dsp": dsp_available,
|
|
100
|
+
"onnx": onnx_available,
|
|
101
|
+
"native": None, # Android 런타임에서만 확인 가능
|
|
102
|
+
},
|
|
103
|
+
"errors": [state_data.get("last_error")] if state_data and state_data.get("last_error") else [],
|
|
104
|
+
"state_file": {
|
|
105
|
+
"path": str(self._state_file.path),
|
|
106
|
+
"stale": stale,
|
|
107
|
+
"updated_at": state_data.get("updated_at") if state_data else None,
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def _check_pid(self) -> tuple[int | None, bool]:
|
|
112
|
+
if self.DEFAULT_PID_FILE.exists():
|
|
113
|
+
try:
|
|
114
|
+
pid = int(self.DEFAULT_PID_FILE.read_text().strip())
|
|
115
|
+
os.kill(pid, 0)
|
|
116
|
+
return pid, True
|
|
117
|
+
except Exception:
|
|
118
|
+
pass
|
|
119
|
+
return None, False
|
|
120
|
+
|
|
121
|
+
def doctor_full(self) -> dict:
|
|
122
|
+
"""기존 TTSEngine doctor() 호출 — 12단계 Vulkan Doctor 포함."""
|
|
123
|
+
lite = self.doctor_lite()
|
|
124
|
+
try:
|
|
125
|
+
from termux_tts.vulkan_probe import VulkanDoctor
|
|
126
|
+
vd = VulkanDoctor()
|
|
127
|
+
lite["vulkan"] = vd.probe() if hasattr(vd, "probe") else {"note": "probe() not available"}
|
|
128
|
+
except Exception as e:
|
|
129
|
+
lite["vulkan_error"] = str(e)
|
|
130
|
+
lite["doctor_level"] = "full"
|
|
131
|
+
return lite
|
|
132
|
+
|
|
133
|
+
def list_models(self) -> dict:
|
|
134
|
+
"""ModelRegistry 기반 + 설치된 파일 스캔."""
|
|
135
|
+
reg_models = self._model_reg.list_all()
|
|
136
|
+
reg_map = {m["model_id"]: m for m in reg_models}
|
|
137
|
+
|
|
138
|
+
# models_dir 스캔 (파일만 존재 = unverified)
|
|
139
|
+
if self._models_dir.exists():
|
|
140
|
+
for p in self._models_dir.glob("*"):
|
|
141
|
+
if p.is_file():
|
|
142
|
+
mid = p.stem
|
|
143
|
+
if mid not in reg_map:
|
|
144
|
+
reg_map[mid] = {
|
|
145
|
+
"model_id": mid,
|
|
146
|
+
"state": "unverified",
|
|
147
|
+
"format": p.suffix.lstrip("."),
|
|
148
|
+
"note": "File found on disk but not verified by AMEVA registry",
|
|
149
|
+
"verified_at": None,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {"models": list(reg_map.values()), "total": len(reg_map),
|
|
153
|
+
"models_dir": str(self._models_dir)}
|
|
154
|
+
|
|
155
|
+
def model_status(self, model_id: str | None = None) -> dict:
|
|
156
|
+
if model_id:
|
|
157
|
+
rec = self._model_reg.get(model_id)
|
|
158
|
+
if rec is None: raise ModelNotFound(model_id)
|
|
159
|
+
return {"model": rec}
|
|
160
|
+
return self.list_models()
|
|
161
|
+
|
|
162
|
+
def install_model(self, request: dict) -> dict:
|
|
163
|
+
from ameva_component import ModelInstaller
|
|
164
|
+
url = request.get("url", ""); filename = request.get("filename", "")
|
|
165
|
+
sha256 = request.get("sha256", "")
|
|
166
|
+
expected_bytes = int(request.get("expected_bytes", 0))
|
|
167
|
+
model_id = request.get("model_id") or Path(filename).stem
|
|
168
|
+
self._models_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
installer = ModelInstaller(self.COMPONENT_ID, self._models_dir, self._model_reg)
|
|
170
|
+
return installer.install(url=url, filename=filename, sha256=sha256,
|
|
171
|
+
expected_bytes=expected_bytes, model_id=model_id)
|
|
172
|
+
|
|
173
|
+
async def activate_model(self, request: dict) -> dict:
|
|
174
|
+
model_id = request.get("model_id", "")
|
|
175
|
+
voice_id = request.get("voice_id") # Model/Voice 분리
|
|
176
|
+
rec = self._model_reg.get(model_id)
|
|
177
|
+
if rec is None: raise ModelNotFound(model_id)
|
|
178
|
+
if ModelState.from_str(rec.get("state", "missing")) not in (ModelState.INSTALLED, ModelState.INACTIVE):
|
|
179
|
+
raise ModelLoadFailed(model_id, f"State is '{rec.get('state')}'")
|
|
180
|
+
with self._act_lock.acquire(timeout=60.0):
|
|
181
|
+
self._model_reg.set_state(model_id, ModelState.ACTIVE)
|
|
182
|
+
self._write_state()
|
|
183
|
+
return {"activated": True, "model_id": model_id, "voice_id": voice_id,
|
|
184
|
+
"rollback": {"attempted": False, "succeeded": False}}
|
|
185
|
+
|
|
186
|
+
async def deactivate_model(self, request: dict) -> dict:
|
|
187
|
+
model_id = request.get("model_id", "")
|
|
188
|
+
self._model_reg.set_state(model_id, ModelState.INACTIVE)
|
|
189
|
+
self._write_state()
|
|
190
|
+
return {"deactivated": True, "model_id": model_id}
|
|
191
|
+
|
|
192
|
+
def list_instances(self) -> dict:
|
|
193
|
+
instances = self._inst_reg.list_all()
|
|
194
|
+
return {"instances": [i.to_dict() for i in instances], "total": len(instances)}
|
|
195
|
+
|
|
196
|
+
async def start_instance(self, request: dict) -> dict:
|
|
197
|
+
backend = request.get("backend", "dsp")
|
|
198
|
+
model_id = request.get("model_id", "dsp-default")
|
|
199
|
+
instance_id = request.get("instance_id") or f"tts-{backend}-{int(time.time())}"
|
|
200
|
+
inst = InstanceStatus(
|
|
201
|
+
instance_id=instance_id, component_id=self.COMPONENT_ID,
|
|
202
|
+
model_id=model_id, state=InstanceState.HOT,
|
|
203
|
+
active_jobs=0, queue_depth=0, max_concurrency=4,
|
|
204
|
+
backend=backend, started_at=time.time(), last_heartbeat=time.time(),
|
|
205
|
+
last_error=None, control_mode=ControlMode.IN_PROCESS,
|
|
206
|
+
)
|
|
207
|
+
self._inst_reg.register(inst)
|
|
208
|
+
self._write_state()
|
|
209
|
+
# Phase 4: Heartbeat 시작 (Worker 시작 트리거)
|
|
210
|
+
self._heartbeat.start()
|
|
211
|
+
return {"instance_id": instance_id, "state": InstanceState.HOT.value, "backend": backend}
|
|
212
|
+
|
|
213
|
+
async def drain_instance(self, instance_id: str) -> dict:
|
|
214
|
+
from ameva_component import InstanceNotFound
|
|
215
|
+
if not self._inst_reg.get(instance_id): raise InstanceNotFound(instance_id)
|
|
216
|
+
self._inst_reg.update_state(instance_id, InstanceState.DRAINING)
|
|
217
|
+
return {"instance_id": instance_id, "state": InstanceState.DRAINING.value}
|
|
218
|
+
|
|
219
|
+
async def stop_instance(self, instance_id: str) -> dict:
|
|
220
|
+
from ameva_component import InstanceNotFound
|
|
221
|
+
if not self._inst_reg.get(instance_id): raise InstanceNotFound(instance_id)
|
|
222
|
+
self._inst_reg.update_state(instance_id, InstanceState.STOPPED)
|
|
223
|
+
self._inst_reg.remove(instance_id)
|
|
224
|
+
# Phase 4: Heartbeat 중단 (정상 종료 트리거)
|
|
225
|
+
remaining = self._inst_reg.list_all()
|
|
226
|
+
if not remaining:
|
|
227
|
+
self._heartbeat.stop()
|
|
228
|
+
else:
|
|
229
|
+
self._write_state()
|
|
230
|
+
return {"instance_id": instance_id, "state": InstanceState.STOPPED.value}
|
|
231
|
+
|
|
232
|
+
def _write_state(self, *, ready: bool | None = None, last_error: str | None = None) -> None:
|
|
233
|
+
ts = now_timestamps()
|
|
234
|
+
hot = [i for i in self._inst_reg.list_all() if i.state == InstanceState.HOT]
|
|
235
|
+
_, pid_alive = self._check_pid()
|
|
236
|
+
_ready = True if ready is None else ready # DSP는 항상 최소 가용
|
|
237
|
+
self._state_file.write({
|
|
238
|
+
"protocol": "ameva-component-status/1", "component_id": self.COMPONENT_ID,
|
|
239
|
+
"component_type": self.COMPONENT_TYPE, "version": self._get_version(),
|
|
240
|
+
"ready": _ready, "degraded": not _ready, **ts,
|
|
241
|
+
"active_models": [i.model_id for i in hot], "last_error": last_error,
|
|
242
|
+
})
|