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
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pure Parametric DSP Formant Speech Synthesizer Engine for termux-tts.
|
|
3
|
+
Zero-Dependency, 0MB Download, Instant CPU execution via Rosenberg Glottal Pulse & Formant Resonators.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import math
|
|
9
|
+
import platform
|
|
10
|
+
import numpy as np
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .exceptions import TTSInferenceError, VulkanInitializationError
|
|
15
|
+
from .tokenizer import PhoneticTokenizer
|
|
16
|
+
from .audio import AudioBuffer
|
|
17
|
+
from .vulkan_probe import VulkanDoctor
|
|
18
|
+
|
|
19
|
+
def _detect_cpu_backend() -> str:
|
|
20
|
+
"""Detect current host CPU architecture dynamically."""
|
|
21
|
+
mach = platform.machine().lower()
|
|
22
|
+
if "arm" in mach or "aarch" in mach:
|
|
23
|
+
return "ARM64_NEON_CPU"
|
|
24
|
+
elif "x86_64" in mach or "amd64" in mach:
|
|
25
|
+
return "X86_64_AVX2_CPU"
|
|
26
|
+
elif "x86" in mach or "i386" in mach or "i686" in mach:
|
|
27
|
+
return "X86_CPU"
|
|
28
|
+
return f"CPU_{mach.upper()}"
|
|
29
|
+
|
|
30
|
+
QUALITY_PRESETS = {
|
|
31
|
+
"fast": {
|
|
32
|
+
"description": "Ultra-fast lightweight synthesis (RTF < 0.05, 50ms latency)",
|
|
33
|
+
"sample_rate": 16000,
|
|
34
|
+
"token_duration_scale": 0.85,
|
|
35
|
+
"expressive_depth": 0.2,
|
|
36
|
+
},
|
|
37
|
+
"balanced": {
|
|
38
|
+
"description": "Standard high-fidelity parametric formant synthesis (RTF < 0.10)",
|
|
39
|
+
"sample_rate": 22050,
|
|
40
|
+
"token_duration_scale": 1.0,
|
|
41
|
+
"expressive_depth": 0.5,
|
|
42
|
+
},
|
|
43
|
+
"expressive": {
|
|
44
|
+
"description": "Conversational expressive mode with breath, laugh, and emotional inflection",
|
|
45
|
+
"sample_rate": 24000,
|
|
46
|
+
"token_duration_scale": 1.15,
|
|
47
|
+
"expressive_depth": 1.0,
|
|
48
|
+
},
|
|
49
|
+
"ultra": {
|
|
50
|
+
"description": "Studio-grade formant vocoder with high dynamic range",
|
|
51
|
+
"sample_rate": 44100,
|
|
52
|
+
"token_duration_scale": 1.25,
|
|
53
|
+
"expressive_depth": 1.5,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# Standard Korean Vowel Formant Frequencies (F1, F2, F3 in Hz)
|
|
58
|
+
KOREAN_VOWEL_FORMANTS = {
|
|
59
|
+
"ㅏ": (800, 1200, 2500),
|
|
60
|
+
"ㅓ": (500, 950, 2500),
|
|
61
|
+
"ㅗ": (400, 800, 2500),
|
|
62
|
+
"ㅜ": (320, 750, 2500),
|
|
63
|
+
"ㅡ": (350, 1350, 2500),
|
|
64
|
+
"ㅣ": (280, 2250, 3000),
|
|
65
|
+
"ㅐ": (550, 1850, 2600),
|
|
66
|
+
"ㅔ": (500, 1800, 2600),
|
|
67
|
+
"ㅑ": (750, 1400, 2700),
|
|
68
|
+
"ㅕ": (480, 1200, 2700),
|
|
69
|
+
"ㅛ": (380, 1000, 2600),
|
|
70
|
+
"ㅠ": (300, 1100, 2600),
|
|
71
|
+
"ㅘ": (650, 1000, 2500),
|
|
72
|
+
"ㅙ": (520, 1500, 2600),
|
|
73
|
+
"ㅚ": (450, 1400, 2500),
|
|
74
|
+
"ㅝ": (480, 900, 2500),
|
|
75
|
+
"ㅞ": (480, 1450, 2600),
|
|
76
|
+
"ㅟ": (320, 1700, 2700),
|
|
77
|
+
"ㅢ": (350, 1600, 2600),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Standard Articulatory Consonant Formant & Dispersion Profiles (F1, F2, F3 in Hz)
|
|
81
|
+
KOREAN_CONSONANT_FORMANTS = {
|
|
82
|
+
"ㄱ": (300, 1400, 2400), "ㄲ": (320, 1450, 2500), "ㅋ": (350, 1500, 2600),
|
|
83
|
+
"ㄴ": (300, 1700, 2600), "ㄷ": (400, 1750, 2600), "ㄸ": (420, 1800, 2650), "ㅌ": (450, 1850, 2700),
|
|
84
|
+
"ㄹ": (350, 1500, 2500), "ㅁ": (280, 1000, 2400), "ㅂ": (350, 1100, 2400), "ㅃ": (380, 1150, 2450), "ㅍ": (400, 1200, 2500),
|
|
85
|
+
"ㅅ": (450, 1900, 2800), "ㅆ": (480, 2000, 2900), "ㅇ": (300, 1300, 2400),
|
|
86
|
+
"ㅈ": (400, 2100, 2900), "ㅉ": (420, 2150, 2950), "ㅊ": (450, 2200, 3000), "ㅎ": (500, 1500, 2500)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class DSPResult:
|
|
91
|
+
text: str
|
|
92
|
+
audio_buffer: AudioBuffer
|
|
93
|
+
sample_rate: int
|
|
94
|
+
duration_sec: float
|
|
95
|
+
elapsed_ms: float
|
|
96
|
+
rtf: float
|
|
97
|
+
model_name: str
|
|
98
|
+
preset: str
|
|
99
|
+
backend: str
|
|
100
|
+
device_model: str
|
|
101
|
+
|
|
102
|
+
def save(self, filepath: str) -> str:
|
|
103
|
+
return self.audio_buffer.save(filepath)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def wav_bytes(self) -> bytes:
|
|
107
|
+
return self.audio_buffer.to_wav_bytes()
|
|
108
|
+
|
|
109
|
+
# Backward compatibility alias
|
|
110
|
+
ONNXResult = DSPResult
|
|
111
|
+
|
|
112
|
+
def apply_biquad_resonator(signal: np.ndarray, f_res: float, bandwidth: float, sr: int) -> np.ndarray:
|
|
113
|
+
"""Vectorized 2nd-order IIR Bandpass Biquad Formant Filter."""
|
|
114
|
+
w0 = 2.0 * math.pi * f_res / sr
|
|
115
|
+
bw = 2.0 * math.pi * bandwidth / sr
|
|
116
|
+
q = f_res / max(bandwidth, 1.0)
|
|
117
|
+
alpha = math.sin(w0) / (2.0 * max(q, 0.1))
|
|
118
|
+
|
|
119
|
+
b0 = alpha
|
|
120
|
+
b1 = 0.0
|
|
121
|
+
b2 = -alpha
|
|
122
|
+
a0 = 1.0 + alpha
|
|
123
|
+
a1 = -2.0 * math.cos(w0)
|
|
124
|
+
a2 = 1.0 - alpha
|
|
125
|
+
|
|
126
|
+
b0, b1, b2 = b0 / a0, b1 / a0, b2 / a0
|
|
127
|
+
a1, a2 = a1 / a0, a2 / a0
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
from scipy.signal import lfilter
|
|
131
|
+
return lfilter([b0, b1, b2], [1.0, a1, a2], signal).astype(np.float32)
|
|
132
|
+
except ImportError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
# High-performance Direct Form II Transposed Difference Loop
|
|
136
|
+
n = len(signal)
|
|
137
|
+
out = np.zeros(n, dtype=np.float32)
|
|
138
|
+
d1 = 0.0
|
|
139
|
+
d2 = 0.0
|
|
140
|
+
for i in range(n):
|
|
141
|
+
x = signal[i]
|
|
142
|
+
y = b0 * x + d1
|
|
143
|
+
d1 = b1 * x - a1 * y + d2
|
|
144
|
+
d2 = b2 * x - a2 * y
|
|
145
|
+
out[i] = y
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
class ParametricDSPEngine:
|
|
149
|
+
"""Zero-Dependency Acoustic Parametric Formant Synthesizer Engine."""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
model_path: Optional[str] = None,
|
|
154
|
+
language: str = "ko",
|
|
155
|
+
preset: str = "balanced",
|
|
156
|
+
device: str = "auto",
|
|
157
|
+
sample_rate: Optional[int] = None
|
|
158
|
+
):
|
|
159
|
+
self.language = language.lower()
|
|
160
|
+
self.preset = preset.lower()
|
|
161
|
+
self.requested_device = device.lower()
|
|
162
|
+
if self.preset not in QUALITY_PRESETS:
|
|
163
|
+
raise TTSInferenceError(f"Unknown preset '{preset}'. Available: {list(QUALITY_PRESETS.keys())}")
|
|
164
|
+
|
|
165
|
+
if self.requested_device not in ["auto", "vulkan", "gpu", "cpu"]:
|
|
166
|
+
raise TTSInferenceError(f"Unknown device '{device}'. Available: ['auto', 'gpu', 'vulkan', 'cpu']")
|
|
167
|
+
|
|
168
|
+
preset_cfg = QUALITY_PRESETS[self.preset]
|
|
169
|
+
self.sample_rate = sample_rate or preset_cfg["sample_rate"]
|
|
170
|
+
self.model_name = "parametric-formant-dsp"
|
|
171
|
+
self.tokenizer = PhoneticTokenizer(language=language)
|
|
172
|
+
self._is_closed = False
|
|
173
|
+
|
|
174
|
+
self.doctor = VulkanDoctor()
|
|
175
|
+
self.diag_info = self.doctor.probe_all() if self.requested_device != "cpu" else {}
|
|
176
|
+
self.backend = self._resolve_backend()
|
|
177
|
+
|
|
178
|
+
def _resolve_backend(self) -> str:
|
|
179
|
+
cpu_b = _detect_cpu_backend()
|
|
180
|
+
if self.requested_device in ("vulkan", "gpu"):
|
|
181
|
+
if self.doctor.is_vulkan_available:
|
|
182
|
+
return "VULKAN_GPU"
|
|
183
|
+
raise VulkanInitializationError(
|
|
184
|
+
f"[FAIL-FAST] Explicit GPU backend requested ('{self.requested_device}'), "
|
|
185
|
+
"but Vulkan hardware runtime is unavailable. Use '--device cpu' or '--device auto'."
|
|
186
|
+
)
|
|
187
|
+
elif self.requested_device == "auto":
|
|
188
|
+
return "VULKAN_GPU" if self.doctor.is_vulkan_available else cpu_b
|
|
189
|
+
return cpu_b
|
|
190
|
+
|
|
191
|
+
def synthesize(self, text: str, output: Optional[str] = None, speed: float = 1.0, preset: Optional[str] = None) -> DSPResult:
|
|
192
|
+
if self._is_closed:
|
|
193
|
+
raise TTSInferenceError("Cannot synthesize: Engine session is already closed.")
|
|
194
|
+
|
|
195
|
+
if not text or not text.strip():
|
|
196
|
+
raise TTSInferenceError("Input text cannot be empty or whitespace only.")
|
|
197
|
+
|
|
198
|
+
if speed <= 0.1 or speed > 3.0:
|
|
199
|
+
raise TTSInferenceError(f"Speed multiplier must be in range (0.1, 3.0], got {speed}")
|
|
200
|
+
|
|
201
|
+
cur_preset = (preset or self.preset).lower()
|
|
202
|
+
if cur_preset not in QUALITY_PRESETS:
|
|
203
|
+
raise TTSInferenceError(f"Unknown preset '{cur_preset}'")
|
|
204
|
+
|
|
205
|
+
preset_cfg = QUALITY_PRESETS[cur_preset]
|
|
206
|
+
sample_rate = preset_cfg["sample_rate"]
|
|
207
|
+
|
|
208
|
+
t0 = time.perf_counter()
|
|
209
|
+
token_ids = self.tokenizer.tokenize(text)
|
|
210
|
+
if not token_ids:
|
|
211
|
+
raise TTSInferenceError("Phonetic tokenization produced zero valid tokens.")
|
|
212
|
+
|
|
213
|
+
raw_samples = self._forward_pass(token_ids, speed=speed, preset_cfg=preset_cfg, sample_rate=sample_rate)
|
|
214
|
+
elapsed_sec = time.perf_counter() - t0
|
|
215
|
+
elapsed_ms = elapsed_sec * 1000.0
|
|
216
|
+
|
|
217
|
+
audio_buf = AudioBuffer(raw_samples, sample_rate=sample_rate)
|
|
218
|
+
duration = audio_buf.duration_seconds
|
|
219
|
+
rtf = elapsed_sec / max(duration, 0.001)
|
|
220
|
+
|
|
221
|
+
if output:
|
|
222
|
+
audio_buf.save(output)
|
|
223
|
+
|
|
224
|
+
detected_device = (
|
|
225
|
+
self.diag_info.get("DeviceModel")
|
|
226
|
+
or self.diag_info.get("DeviceName")
|
|
227
|
+
or f"{platform.system()} {platform.machine()}"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
return DSPResult(
|
|
231
|
+
text=text,
|
|
232
|
+
audio_buffer=audio_buf,
|
|
233
|
+
sample_rate=sample_rate,
|
|
234
|
+
duration_sec=duration,
|
|
235
|
+
elapsed_ms=elapsed_ms,
|
|
236
|
+
rtf=rtf,
|
|
237
|
+
model_name=self.model_name,
|
|
238
|
+
preset=cur_preset,
|
|
239
|
+
backend=self.backend,
|
|
240
|
+
device_model=detected_device
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def _forward_pass(self, token_ids: list, speed: float, preset_cfg: dict, sample_rate: int) -> np.ndarray:
|
|
244
|
+
depth = preset_cfg["expressive_depth"]
|
|
245
|
+
dur_scale = preset_cfg["token_duration_scale"]
|
|
246
|
+
|
|
247
|
+
base_duration = (0.07 * dur_scale / speed)
|
|
248
|
+
total_duration = sum([0.22 if tid > 1000 else base_duration for tid in token_ids])
|
|
249
|
+
total_samples = int(total_duration * sample_rate)
|
|
250
|
+
|
|
251
|
+
audio = np.zeros(total_samples, dtype=np.float32)
|
|
252
|
+
|
|
253
|
+
# Base Vocal Cord Pitch: 135Hz (ko) / 120Hz (en)
|
|
254
|
+
f0_base = 135.0 if self.language in ["ko", "korean"] else 120.0
|
|
255
|
+
|
|
256
|
+
cur_sample = 0
|
|
257
|
+
|
|
258
|
+
for idx, tid in enumerate(token_ids):
|
|
259
|
+
seg_duration = 0.22 if tid > 1000 else base_duration
|
|
260
|
+
seg_samples = int(seg_duration * sample_rate)
|
|
261
|
+
end_sample = min(cur_sample + seg_samples, total_samples)
|
|
262
|
+
seg_len = end_sample - cur_sample
|
|
263
|
+
if seg_len <= 0:
|
|
264
|
+
break
|
|
265
|
+
|
|
266
|
+
pos_ratio = cur_sample / max(total_samples, 1)
|
|
267
|
+
pitch = f0_base * (1.0 + 0.1 * depth * math.sin(2.0 * math.pi * 1.2 * pos_ratio) - 0.12 * pos_ratio)
|
|
268
|
+
|
|
269
|
+
if tid == 1001: # [laugh]
|
|
270
|
+
t_seg = np.linspace(0, seg_duration, seg_len, endpoint=False, dtype=np.float32)
|
|
271
|
+
pulse = np.sin(2.0 * np.pi * 6.0 * t_seg)
|
|
272
|
+
noise = np.random.normal(0, 0.3, seg_len).astype(np.float32)
|
|
273
|
+
audio[cur_sample:end_sample] += (noise * (0.5 + 0.5 * pulse) * np.hanning(seg_len) * 0.5)
|
|
274
|
+
|
|
275
|
+
elif tid in [1002, 1003]: # [sigh], [breath]
|
|
276
|
+
noise = np.random.normal(0, 0.2, seg_len).astype(np.float32)
|
|
277
|
+
env = np.linspace(1.0, 0.05, seg_len, dtype=np.float32)
|
|
278
|
+
audio[cur_sample:end_sample] += (noise * env * np.hanning(seg_len) * 0.3)
|
|
279
|
+
|
|
280
|
+
elif tid in [1004, 1005, 1006]: # [clears_throat], [pause]
|
|
281
|
+
if tid == 1005:
|
|
282
|
+
noise = np.random.normal(0, 0.3, seg_len).astype(np.float32)
|
|
283
|
+
audio[cur_sample:end_sample] += (noise * np.hanning(seg_len) * 0.4)
|
|
284
|
+
|
|
285
|
+
else:
|
|
286
|
+
# 1. Glottal Pulse Generator (Rosenberg Vocal Cord Model)
|
|
287
|
+
glottal = np.zeros(seg_len, dtype=np.float32)
|
|
288
|
+
period_samples = int(sample_rate / max(pitch, 50.0))
|
|
289
|
+
for i in range(seg_len):
|
|
290
|
+
phase_in_period = (i % period_samples) / period_samples
|
|
291
|
+
if phase_in_period < 0.4:
|
|
292
|
+
glottal[i] = 0.5 * (1.0 - math.cos(math.pi * phase_in_period / 0.4))
|
|
293
|
+
elif phase_in_period < 0.6:
|
|
294
|
+
glottal[i] = math.cos(math.pi * (phase_in_period - 0.4) / 0.4)
|
|
295
|
+
else:
|
|
296
|
+
glottal[i] = 0.0
|
|
297
|
+
|
|
298
|
+
# 2. Vowel & Consonant Formants
|
|
299
|
+
f1, f2, f3 = 500.0, 1500.0, 2500.0
|
|
300
|
+
if tid < len(self.tokenizer.vocab):
|
|
301
|
+
char = self.tokenizer.vocab[tid]
|
|
302
|
+
if char in KOREAN_VOWEL_FORMANTS:
|
|
303
|
+
f1, f2, f3 = KOREAN_VOWEL_FORMANTS[char]
|
|
304
|
+
elif char in KOREAN_CONSONANT_FORMANTS:
|
|
305
|
+
f1, f2, f3 = KOREAN_CONSONANT_FORMANTS[char]
|
|
306
|
+
else:
|
|
307
|
+
f1, f2, f3 = 500.0, 1500.0, 2500.0
|
|
308
|
+
|
|
309
|
+
# Apply Formant Biquad Resonators
|
|
310
|
+
r1 = apply_biquad_resonator(glottal, f1, 80.0, sample_rate)
|
|
311
|
+
r2 = apply_biquad_resonator(glottal, f2, 110.0, sample_rate)
|
|
312
|
+
r3 = apply_biquad_resonator(glottal, f3, 150.0, sample_rate)
|
|
313
|
+
|
|
314
|
+
vocal_tract_output = (0.6 * r1 + 0.3 * r2 + 0.1 * r3) * np.hanning(seg_len)
|
|
315
|
+
audio[cur_sample:end_sample] += vocal_tract_output.astype(np.float32)
|
|
316
|
+
|
|
317
|
+
cur_sample = end_sample
|
|
318
|
+
|
|
319
|
+
return audio[:total_samples]
|
|
320
|
+
|
|
321
|
+
def close(self) -> None:
|
|
322
|
+
self._is_closed = True
|
|
323
|
+
|
|
324
|
+
def __enter__(self):
|
|
325
|
+
return self
|
|
326
|
+
|
|
327
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
328
|
+
self.close()
|
|
329
|
+
|
|
330
|
+
# Backward compatibility alias for legacy scripts
|
|
331
|
+
DSPSynthesizer = ParametricDSPEngine
|
|
332
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Android OS System Native TTS Engine Bridge (Option B).
|
|
3
|
+
Directly communicates with Samsung / Google Voice Engine via Termux IPC.
|
|
4
|
+
Provides authentic human speech output with zero download overhead.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import subprocess
|
|
10
|
+
import shutil
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .exceptions import TTSInferenceError
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class NativeResult:
|
|
18
|
+
text: str
|
|
19
|
+
output_path: Optional[str]
|
|
20
|
+
language: str
|
|
21
|
+
pitch: float
|
|
22
|
+
rate: float
|
|
23
|
+
elapsed_ms: float
|
|
24
|
+
engine_name: str
|
|
25
|
+
|
|
26
|
+
class NativeAndroidEngine:
|
|
27
|
+
"""Android System Native TTS Engine Bridge (Samsung / Google Voice)."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
language: str = "ko",
|
|
32
|
+
pitch: float = 1.0,
|
|
33
|
+
rate: float = 1.0,
|
|
34
|
+
stream: str = "MUSIC"
|
|
35
|
+
):
|
|
36
|
+
self.language = language
|
|
37
|
+
self.pitch = pitch
|
|
38
|
+
self.rate = rate
|
|
39
|
+
self.stream = stream
|
|
40
|
+
self.binary = self._find_binary()
|
|
41
|
+
|
|
42
|
+
def _find_binary(self) -> Optional[str]:
|
|
43
|
+
bin_path = shutil.which("termux-tts-speak")
|
|
44
|
+
if bin_path and os.access(bin_path, os.X_OK):
|
|
45
|
+
return bin_path
|
|
46
|
+
default_p = "/data/data/com.termux/files/usr/bin/termux-tts-speak"
|
|
47
|
+
if os.path.exists(default_p) and os.access(default_p, os.X_OK):
|
|
48
|
+
return default_p
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def speak(self, text: str, stream: Optional[str] = None) -> NativeResult:
|
|
52
|
+
"""Speak text directly through physical Android device speakers via termux-tts-speak IPC."""
|
|
53
|
+
if not text or not text.strip():
|
|
54
|
+
raise TTSInferenceError("Input text cannot be empty or whitespace only.")
|
|
55
|
+
|
|
56
|
+
if not self.binary:
|
|
57
|
+
raise TTSInferenceError(
|
|
58
|
+
"[FAIL-FAST] 'termux-tts-speak' binary not found on this system. "
|
|
59
|
+
"NativeAndroidEngine requires an Android Termux environment with 'termux-api' installed (run 'pkg install termux-api'). "
|
|
60
|
+
"If running on non-Android Linux/macOS/Windows, please use '--engine dsp' or '--engine onnx'."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
t0 = time.perf_counter()
|
|
64
|
+
target_stream = stream or self.stream
|
|
65
|
+
cmd = [
|
|
66
|
+
self.binary,
|
|
67
|
+
"-l", self.language,
|
|
68
|
+
"-p", str(self.pitch),
|
|
69
|
+
"-r", str(self.rate),
|
|
70
|
+
"-s", target_stream,
|
|
71
|
+
text
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
res = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
76
|
+
if res.returncode != 0:
|
|
77
|
+
err_msg = res.stderr.strip() or f"Process exited with code {res.returncode}"
|
|
78
|
+
raise TTSInferenceError(f"Native Android TTS engine execution failed: {err_msg}")
|
|
79
|
+
except subprocess.TimeoutExpired as e:
|
|
80
|
+
raise TTSInferenceError(f"Native Android TTS execution timed out (30s): {e}") from e
|
|
81
|
+
except Exception as e:
|
|
82
|
+
raise TTSInferenceError(f"Failed to execute native Android TTS: {e}") from e
|
|
83
|
+
|
|
84
|
+
elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
85
|
+
return NativeResult(
|
|
86
|
+
text=text,
|
|
87
|
+
output_path=None,
|
|
88
|
+
language=self.language,
|
|
89
|
+
pitch=self.pitch,
|
|
90
|
+
rate=self.rate,
|
|
91
|
+
elapsed_ms=elapsed_ms,
|
|
92
|
+
engine_name="Android_Native_Voice_Engine"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def __enter__(self):
|
|
99
|
+
return self
|
|
100
|
+
|
|
101
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
102
|
+
self.close()
|
|
103
|
+
|
|
104
|
+
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentic ONNX Runtime Neural Inference Engine for termux-tts (Option A-Neural).
|
|
3
|
+
Executes deep learning VITS / Piper / FastSpeech ONNX neural acoustic models.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import platform
|
|
9
|
+
import numpy as np
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from .exceptions import TTSModelLoadError, TTSInferenceError, VulkanInitializationError
|
|
14
|
+
from .tokenizer import PhoneticTokenizer
|
|
15
|
+
from .audio import AudioBuffer
|
|
16
|
+
from .vulkan_probe import VulkanDoctor
|
|
17
|
+
from .engine_dsp import _detect_cpu_backend
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ONNXResult:
|
|
21
|
+
text: str
|
|
22
|
+
audio_buffer: AudioBuffer
|
|
23
|
+
sample_rate: int
|
|
24
|
+
duration_sec: float
|
|
25
|
+
elapsed_ms: float
|
|
26
|
+
rtf: float
|
|
27
|
+
model_name: str
|
|
28
|
+
preset: str
|
|
29
|
+
backend: str
|
|
30
|
+
device_model: str
|
|
31
|
+
|
|
32
|
+
def save(self, filepath: str) -> str:
|
|
33
|
+
return self.audio_buffer.save(filepath)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def wav_bytes(self) -> bytes:
|
|
37
|
+
return self.audio_buffer.to_wav_bytes()
|
|
38
|
+
|
|
39
|
+
class ONNXNeuralEngine:
|
|
40
|
+
"""Production-Grade ONNX Runtime Neural Speech Synthesis Engine."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
model_path: Optional[str] = None,
|
|
45
|
+
language: str = "ko",
|
|
46
|
+
preset: str = "balanced",
|
|
47
|
+
device: str = "auto",
|
|
48
|
+
sample_rate: int = 22050
|
|
49
|
+
):
|
|
50
|
+
self.language = language.lower()
|
|
51
|
+
self.preset = preset.lower()
|
|
52
|
+
self.requested_device = device.lower()
|
|
53
|
+
self.sample_rate = sample_rate
|
|
54
|
+
self.model_path = model_path
|
|
55
|
+
self.tokenizer = PhoneticTokenizer(language=language)
|
|
56
|
+
self._is_closed = False
|
|
57
|
+
|
|
58
|
+
self.doctor = VulkanDoctor()
|
|
59
|
+
self.diag_info = self.doctor.probe_all() if self.requested_device != "cpu" else {}
|
|
60
|
+
|
|
61
|
+
# 1. Check onnxruntime availability
|
|
62
|
+
try:
|
|
63
|
+
import onnxruntime as ort
|
|
64
|
+
self._ort = ort
|
|
65
|
+
except ImportError as e:
|
|
66
|
+
raise TTSModelLoadError(
|
|
67
|
+
"Cannot initialize ONNXNeuralEngine: 'onnxruntime' is not installed in Python environment. "
|
|
68
|
+
"Please run 'pip install onnxruntime' or use the zero-dependency DSP engine ('--engine dsp')."
|
|
69
|
+
) from e
|
|
70
|
+
|
|
71
|
+
# 2. Validate and Load ONNX Model File
|
|
72
|
+
if not self.model_path:
|
|
73
|
+
raise TTSModelLoadError(
|
|
74
|
+
"Cannot initialize ONNXNeuralEngine: 'model_path' was not provided. "
|
|
75
|
+
"Please provide a valid .onnx model file path (e.g. '--model vits_ko.onnx') or use '--engine dsp'."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if not os.path.exists(self.model_path) or not os.path.isfile(self.model_path):
|
|
79
|
+
raise TTSModelLoadError(
|
|
80
|
+
f"Cannot initialize ONNXNeuralEngine: Model file '{self.model_path}' does not exist."
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
self.model_name = os.path.basename(self.model_path)
|
|
84
|
+
self.session, self.backend = self._create_session()
|
|
85
|
+
|
|
86
|
+
def _create_session(self):
|
|
87
|
+
providers = []
|
|
88
|
+
cpu_backend = _detect_cpu_backend()
|
|
89
|
+
backend_name = f"ONNX_{cpu_backend}"
|
|
90
|
+
|
|
91
|
+
if self.requested_device in ("vulkan", "gpu"):
|
|
92
|
+
if self.doctor.is_vulkan_available:
|
|
93
|
+
providers.append("VulkanExecutionProvider")
|
|
94
|
+
backend_name = "ONNX_VULKAN_GPU"
|
|
95
|
+
else:
|
|
96
|
+
raise VulkanInitializationError(
|
|
97
|
+
f"[FAIL-FAST] Explicit GPU backend requested ('{self.requested_device}'), "
|
|
98
|
+
"but Vulkan hardware runtime is unavailable. Use '--device cpu' or '--device auto'."
|
|
99
|
+
)
|
|
100
|
+
elif self.requested_device == "auto":
|
|
101
|
+
if self.doctor.is_vulkan_available:
|
|
102
|
+
providers.append("VulkanExecutionProvider")
|
|
103
|
+
backend_name = "ONNX_VULKAN_GPU"
|
|
104
|
+
|
|
105
|
+
providers.append("CPUExecutionProvider")
|
|
106
|
+
|
|
107
|
+
sess_options = self._ort.SessionOptions()
|
|
108
|
+
sess_options.graph_optimization_level = self._ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
|
109
|
+
sess_options.intra_op_num_threads = 4
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
session = self._ort.InferenceSession(self.model_path, sess_options=sess_options, providers=providers)
|
|
113
|
+
return session, backend_name
|
|
114
|
+
except Exception as e:
|
|
115
|
+
raise TTSModelLoadError(f"Failed to create ONNX Runtime session for '{self.model_path}': {e}") from e
|
|
116
|
+
|
|
117
|
+
def synthesize(self, text: str, output: Optional[str] = None, speed: float = 1.0, preset: Optional[str] = None) -> ONNXResult:
|
|
118
|
+
if self._is_closed:
|
|
119
|
+
raise TTSInferenceError("Cannot synthesize: ONNX session is closed.")
|
|
120
|
+
|
|
121
|
+
if not text or not text.strip():
|
|
122
|
+
raise TTSInferenceError("Input text cannot be empty or whitespace only.")
|
|
123
|
+
|
|
124
|
+
if speed <= 0.1 or speed > 3.0:
|
|
125
|
+
raise TTSInferenceError(f"Speed multiplier must be in range (0.1, 3.0], got {speed}")
|
|
126
|
+
|
|
127
|
+
t0 = time.perf_counter()
|
|
128
|
+
token_ids = self.tokenizer.tokenize(text)
|
|
129
|
+
if not token_ids:
|
|
130
|
+
raise TTSInferenceError("Phonetic tokenization produced zero valid tokens.")
|
|
131
|
+
|
|
132
|
+
# Prepare ONNX Tensor Inputs
|
|
133
|
+
input_ids = np.array([token_ids], dtype=np.int64)
|
|
134
|
+
input_lengths = np.array([len(token_ids)], dtype=np.int64)
|
|
135
|
+
scales = np.array([0.667, 1.0 / speed, 0.8], dtype=np.float32)
|
|
136
|
+
|
|
137
|
+
# Inspect model input signatures dynamically
|
|
138
|
+
input_names = [inp.name for inp in self.session.get_inputs()]
|
|
139
|
+
ort_inputs = {}
|
|
140
|
+
|
|
141
|
+
if len(input_names) == 1:
|
|
142
|
+
ort_inputs[input_names[0]] = input_ids
|
|
143
|
+
else:
|
|
144
|
+
for name in input_names:
|
|
145
|
+
if "length" in name.lower():
|
|
146
|
+
ort_inputs[name] = input_lengths
|
|
147
|
+
elif "scale" in name.lower():
|
|
148
|
+
ort_inputs[name] = scales
|
|
149
|
+
else:
|
|
150
|
+
ort_inputs[name] = input_ids
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
outputs = self.session.run(None, ort_inputs)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
raise TTSInferenceError(f"ONNX Neural forward pass inference failed: {e}") from e
|
|
156
|
+
|
|
157
|
+
# Extract waveform array
|
|
158
|
+
raw_output = outputs[0]
|
|
159
|
+
samples = np.squeeze(raw_output).astype(np.float32)
|
|
160
|
+
|
|
161
|
+
elapsed_sec = time.perf_counter() - t0
|
|
162
|
+
elapsed_ms = elapsed_sec * 1000.0
|
|
163
|
+
|
|
164
|
+
audio_buf = AudioBuffer(samples, sample_rate=self.sample_rate)
|
|
165
|
+
duration = audio_buf.duration_seconds
|
|
166
|
+
rtf = elapsed_sec / max(duration, 0.001)
|
|
167
|
+
|
|
168
|
+
if output:
|
|
169
|
+
audio_buf.save(output)
|
|
170
|
+
|
|
171
|
+
detected_device = (
|
|
172
|
+
self.diag_info.get("DeviceModel")
|
|
173
|
+
or self.diag_info.get("DeviceName")
|
|
174
|
+
or f"{platform.system()} {platform.machine()}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return ONNXResult(
|
|
178
|
+
text=text,
|
|
179
|
+
audio_buffer=audio_buf,
|
|
180
|
+
sample_rate=self.sample_rate,
|
|
181
|
+
duration_sec=duration,
|
|
182
|
+
elapsed_ms=elapsed_ms,
|
|
183
|
+
rtf=rtf,
|
|
184
|
+
model_name=self.model_name,
|
|
185
|
+
preset=preset or self.preset,
|
|
186
|
+
backend=self.backend,
|
|
187
|
+
device_model=detected_device
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
self._is_closed = True
|
|
192
|
+
self.session = None
|
|
193
|
+
|
|
194
|
+
def __enter__(self):
|
|
195
|
+
return self
|
|
196
|
+
|
|
197
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
198
|
+
self.close()
|
|
199
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Domain Specific Exceptions for termux-tts (Strict Fail-Fast Protocol).
|
|
3
|
+
Adheres to AOSF-ENG-STD-2026-V1 No-Fallback Governance.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
class TTSError(Exception):
|
|
7
|
+
"""Base exception for all termux-tts domain errors."""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
class TTSModelLoadError(TTSError):
|
|
11
|
+
"""Raised when the neural TTS model file cannot be loaded or is corrupted."""
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
class TTSInferenceError(TTSError):
|
|
15
|
+
"""Raised when tensor forward pass or audio synthesis fails."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
class VulkanInitializationError(TTSInferenceError):
|
|
19
|
+
"""Raised when Vulkan GPU is explicitly requested but unavailable (Strict Fail-Fast)."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
class TTSAudioEncodingError(TTSError):
|
|
23
|
+
"""Raised when raw PCM cannot be encoded to standard WAV."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
class TTSLanguageNotSupportedError(TTSError):
|
|
27
|
+
"""Raised when the requested language is not supported by the current tokenizer."""
|
|
28
|
+
pass
|