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.
Files changed (43) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +31 -0
  3. package/README.pypi.md +47 -0
  4. package/bin/cli.js +33 -0
  5. package/binding_node/index.js +117 -0
  6. package/binding_node/test_node.js +40 -0
  7. package/doc.config.yaml +98 -0
  8. package/docs/benchmarks.md +12 -0
  9. package/docs/guide.md +552 -0
  10. package/docs/tts_guide.md +552 -0
  11. package/dsp_test.wav +0 -0
  12. package/expressive_demo.wav +0 -0
  13. package/g2p_test.wav +0 -0
  14. package/index.js +5 -0
  15. package/install.sh +53 -0
  16. package/package.json +31 -0
  17. package/pyproject.toml +43 -0
  18. package/setup.py +38 -0
  19. package/termux_tts/__init__.py +47 -0
  20. package/termux_tts/adapter.py +99 -0
  21. package/termux_tts/audio.py +75 -0
  22. package/termux_tts/cli.py +82 -0
  23. package/termux_tts/control/__init__.py +4 -0
  24. package/termux_tts/control/component.py +242 -0
  25. package/termux_tts/control/errors.py +52 -0
  26. package/termux_tts/control/instances.py +137 -0
  27. package/termux_tts/control/models.py +151 -0
  28. package/termux_tts/control/status.py +13 -0
  29. package/termux_tts/engine.py +157 -0
  30. package/termux_tts/engine_dsp.py +332 -0
  31. package/termux_tts/engine_native.py +104 -0
  32. package/termux_tts/engine_onnx.py +199 -0
  33. package/termux_tts/exceptions.py +28 -0
  34. package/termux_tts/g2p_korean.py +242 -0
  35. package/termux_tts/tokenizer.py +184 -0
  36. package/termux_tts/vulkan_probe.py +25 -0
  37. package/test_cli.wav +0 -0
  38. package/tests/test_expressive_presets.py +29 -0
  39. package/tests/test_g2p_korean.py +73 -0
  40. package/tests/test_granular_tts.py +141 -0
  41. package/tests/test_native_engine.py +38 -0
  42. package/tests/test_onnx_engine.py +48 -0
  43. 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