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,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Production-Grade Invariant & Acoustic Safety Test Suite for termux-tts.
|
|
3
|
+
Validates G2P correctness, Number Normalization, Acoustic Signal Energy, Filter Stability, and Security Bounds.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import pytest
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
import termux_tts as tts
|
|
12
|
+
from termux_tts.tokenizer import PhoneticTokenizer, decompose_hangul
|
|
13
|
+
from termux_tts.audio import AudioBuffer
|
|
14
|
+
from termux_tts.engine import TTSEngine, load
|
|
15
|
+
from termux_tts.engine_dsp import apply_biquad_resonator
|
|
16
|
+
from termux_tts.exceptions import (
|
|
17
|
+
TTSError,
|
|
18
|
+
TTSModelLoadError,
|
|
19
|
+
TTSInferenceError,
|
|
20
|
+
TTSAudioEncodingError,
|
|
21
|
+
TTSLanguageNotSupportedError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# ==============================================================================
|
|
25
|
+
# 1. Phonetic Tokenization & Number Normalization Invariants
|
|
26
|
+
# ==============================================================================
|
|
27
|
+
|
|
28
|
+
def test_hangul_jamo_decomposition():
|
|
29
|
+
jamos = decompose_hangul("한")
|
|
30
|
+
assert jamos == ["ㅎ", "ㅏ", "ㄴ"]
|
|
31
|
+
jamos_no_jong = decompose_hangul("가")
|
|
32
|
+
assert jamos_no_jong == ["ㄱ", "ㅏ"]
|
|
33
|
+
|
|
34
|
+
def test_korean_number_normalization():
|
|
35
|
+
from termux_tts.tokenizer import normalize_numbers_korean
|
|
36
|
+
tok = PhoneticTokenizer(language="ko")
|
|
37
|
+
# 1. Place-value Sino-Korean string conversions
|
|
38
|
+
assert normalize_numbers_korean("100") == "백"
|
|
39
|
+
assert normalize_numbers_korean("1234") == "천이백삼십사"
|
|
40
|
+
assert normalize_numbers_korean("10000") == "만"
|
|
41
|
+
assert normalize_numbers_korean("2026년 9월 1일") == "이천이십육년 구월 일일"
|
|
42
|
+
|
|
43
|
+
# 2. G2P-applied phonetic normalization
|
|
44
|
+
phonetic_1234 = tok.normalize_text("1234")
|
|
45
|
+
assert "처니" in phonetic_1234 # Liaison: 천+이 -> 처니
|
|
46
|
+
assert "백" in phonetic_1234
|
|
47
|
+
|
|
48
|
+
tokens = tok.tokenize("2026년 9월 1일")
|
|
49
|
+
assert len(tokens) > 10
|
|
50
|
+
assert all(isinstance(t, int) for t in tokens)
|
|
51
|
+
|
|
52
|
+
def test_english_number_and_word_tokenization():
|
|
53
|
+
tok = PhoneticTokenizer(language="en")
|
|
54
|
+
norm_text = tok.normalize_text("System 123 active")
|
|
55
|
+
assert "one" in norm_text
|
|
56
|
+
assert "two" in norm_text
|
|
57
|
+
assert "three" in norm_text
|
|
58
|
+
tokens = tok.tokenize("System 123 active")
|
|
59
|
+
assert len(tokens) > 10
|
|
60
|
+
|
|
61
|
+
def test_unsupported_language_guard():
|
|
62
|
+
with pytest.raises(TTSLanguageNotSupportedError):
|
|
63
|
+
PhoneticTokenizer(language="fr_unsupported")
|
|
64
|
+
|
|
65
|
+
# ==============================================================================
|
|
66
|
+
# 2. Audio Buffer & Acoustic Signal Invariants
|
|
67
|
+
# ==============================================================================
|
|
68
|
+
|
|
69
|
+
def test_audio_buffer_normalization():
|
|
70
|
+
raw = np.array([2.5, -3.0, 1.0, 0.5], dtype=np.float32)
|
|
71
|
+
buf = AudioBuffer(raw, sample_rate=22050)
|
|
72
|
+
assert np.max(np.abs(buf.samples)) <= 1.0
|
|
73
|
+
assert not np.isnan(buf.samples).any()
|
|
74
|
+
assert not np.isinf(buf.samples).any()
|
|
75
|
+
|
|
76
|
+
def test_riff_wav_byte_encoding():
|
|
77
|
+
raw = np.sin(np.linspace(0, 2 * np.pi * 440, 2205, dtype=np.float32))
|
|
78
|
+
buf = AudioBuffer(raw, sample_rate=22050)
|
|
79
|
+
wav_bytes = buf.to_wav_bytes()
|
|
80
|
+
assert wav_bytes.startswith(b"RIFF")
|
|
81
|
+
assert b"WAVE" in wav_bytes
|
|
82
|
+
assert len(wav_bytes) > 2205 * 2
|
|
83
|
+
|
|
84
|
+
def test_biquad_resonator_stability():
|
|
85
|
+
"""Verify Biquad resonator does not explode or produce NaNs."""
|
|
86
|
+
impulse = np.zeros(1000, dtype=np.float32)
|
|
87
|
+
impulse[0] = 1.0
|
|
88
|
+
filtered = apply_biquad_resonator(impulse, f_res=800.0, bandwidth=80.0, sr=22050)
|
|
89
|
+
assert not np.isnan(filtered).any()
|
|
90
|
+
assert not np.isinf(filtered).any()
|
|
91
|
+
assert np.max(np.abs(filtered)) < 5.0
|
|
92
|
+
|
|
93
|
+
# ==============================================================================
|
|
94
|
+
# 3. Acoustic Synthesis & Fail-Fast Integrity
|
|
95
|
+
# ==============================================================================
|
|
96
|
+
|
|
97
|
+
def test_neural_synthesis_korean_acoustics():
|
|
98
|
+
with load(language="ko") as engine:
|
|
99
|
+
res = engine.synthesize("안녕하세요, 텀묵스 음향 합성 무결성 검증입니다.")
|
|
100
|
+
assert res.duration_sec > 0.5
|
|
101
|
+
assert res.sample_rate == 22050
|
|
102
|
+
assert res.rtf < 0.5
|
|
103
|
+
assert len(res.wav_bytes) > 1000
|
|
104
|
+
# Acoustic energy check: verify non-silent valid speech signal
|
|
105
|
+
rms = np.sqrt(np.mean(res.audio_buffer.samples ** 2))
|
|
106
|
+
assert rms > 0.005, f"Audio signal is virtually silent (RMS={rms})"
|
|
107
|
+
|
|
108
|
+
def test_neural_synthesis_english_acoustics():
|
|
109
|
+
with load(language="en") as engine:
|
|
110
|
+
res = engine.synthesize("Hello world, this is termux speech synthesis.")
|
|
111
|
+
assert res.duration_sec > 0.5
|
|
112
|
+
rms = np.sqrt(np.mean(res.audio_buffer.samples ** 2))
|
|
113
|
+
assert rms > 0.005
|
|
114
|
+
|
|
115
|
+
def test_zero_fallback_error_guards():
|
|
116
|
+
with load(language="ko") as engine:
|
|
117
|
+
with pytest.raises(TTSInferenceError):
|
|
118
|
+
engine.synthesize("")
|
|
119
|
+
with pytest.raises(TTSInferenceError):
|
|
120
|
+
engine.synthesize(" ")
|
|
121
|
+
with pytest.raises(TTSInferenceError):
|
|
122
|
+
engine.synthesize("Hello", speed=-1.0)
|
|
123
|
+
with pytest.raises(TTSInferenceError):
|
|
124
|
+
engine.synthesize("Hello", speed=5.0)
|
|
125
|
+
|
|
126
|
+
# ==============================================================================
|
|
127
|
+
# 4. Lifecycle & Performance Scaling
|
|
128
|
+
# ==============================================================================
|
|
129
|
+
|
|
130
|
+
def test_raii_context_manager_lifecycle():
|
|
131
|
+
with load(language="ko") as engine:
|
|
132
|
+
assert not engine._is_closed
|
|
133
|
+
assert engine._is_closed
|
|
134
|
+
with pytest.raises(TTSInferenceError):
|
|
135
|
+
engine.synthesize("Valid text")
|
|
136
|
+
|
|
137
|
+
def test_speed_scaling_contract():
|
|
138
|
+
with load(language="ko") as engine:
|
|
139
|
+
res_normal = engine.synthesize("속도 테스트 문장입니다.", speed=1.0)
|
|
140
|
+
res_fast = engine.synthesize("속도 테스트 문장입니다.", speed=2.0)
|
|
141
|
+
assert res_fast.duration_sec < res_normal.duration_sec
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit Tests for Option B (Android Native System Voice Engine Bridge).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import termux_tts as tts
|
|
7
|
+
from termux_tts.engine_native import NativeAndroidEngine
|
|
8
|
+
from termux_tts.exceptions import TTSInferenceError
|
|
9
|
+
|
|
10
|
+
def test_native_engine_initialization():
|
|
11
|
+
engine = NativeAndroidEngine(language="ko", pitch=1.0, rate=1.0, stream="MUSIC")
|
|
12
|
+
assert engine.language == "ko"
|
|
13
|
+
assert engine.stream == "MUSIC"
|
|
14
|
+
|
|
15
|
+
def test_native_engine_speak_empty_guard():
|
|
16
|
+
engine = NativeAndroidEngine(language="ko")
|
|
17
|
+
with pytest.raises(TTSInferenceError):
|
|
18
|
+
engine.speak("")
|
|
19
|
+
with pytest.raises(TTSInferenceError):
|
|
20
|
+
engine.speak(" ")
|
|
21
|
+
|
|
22
|
+
def test_native_engine_speak_execution():
|
|
23
|
+
with tts.load(engine="native", language="ko") as engine:
|
|
24
|
+
if not engine.binary:
|
|
25
|
+
# 1. Non-Android host must fail-fast with strict TTSInferenceError (Zero-Fallback)
|
|
26
|
+
with pytest.raises(TTSInferenceError) as excinfo:
|
|
27
|
+
engine.speak("테스트 발화입니다.")
|
|
28
|
+
assert "FAIL-FAST" in str(excinfo.value)
|
|
29
|
+
print(f"\n[PASS NATIVE FAIL-FAST] Correctly rejected: {excinfo.value}")
|
|
30
|
+
else:
|
|
31
|
+
# 2. Genuine Android Termux system execution
|
|
32
|
+
res = engine.speak("테스트 발화입니다.")
|
|
33
|
+
assert res.engine_name == "Android_Native_Voice_Engine"
|
|
34
|
+
assert res.language == "ko"
|
|
35
|
+
assert res.elapsed_ms >= 0.0
|
|
36
|
+
print(f"\n[PASS NATIVE SPEAK] Engine: {res.engine_name} | Elapsed: {res.elapsed_ms:.2f}ms")
|
|
37
|
+
|
|
38
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit and Integration Tests for Real ONNX Neural Inference Engine & Gateway Routing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import pytest
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
import termux_tts as tts
|
|
10
|
+
from termux_tts.engine_onnx import ONNXNeuralEngine
|
|
11
|
+
from termux_tts.engine_dsp import ParametricDSPEngine
|
|
12
|
+
from termux_tts.exceptions import TTSModelLoadError, TTSInferenceError
|
|
13
|
+
|
|
14
|
+
def test_onnx_engine_missing_model_fail_fast():
|
|
15
|
+
"""Verify ONNXNeuralEngine raises TTSModelLoadError when model_path is None or onnxruntime missing."""
|
|
16
|
+
with pytest.raises(TTSModelLoadError) as excinfo:
|
|
17
|
+
ONNXNeuralEngine(model_path=None)
|
|
18
|
+
err_str = str(excinfo.value)
|
|
19
|
+
assert "model_path" in err_str or "onnxruntime" in err_str
|
|
20
|
+
|
|
21
|
+
def test_onnx_engine_nonexistent_file_fail_fast():
|
|
22
|
+
"""Verify ONNXNeuralEngine raises TTSModelLoadError when file does not exist or onnxruntime missing."""
|
|
23
|
+
with pytest.raises(TTSModelLoadError) as excinfo:
|
|
24
|
+
ONNXNeuralEngine(model_path="nonexistent_vits_model.onnx")
|
|
25
|
+
err_str = str(excinfo.value)
|
|
26
|
+
assert "does not exist" in err_str or "onnxruntime" in err_str
|
|
27
|
+
|
|
28
|
+
def test_dsp_engine_explicit_execution():
|
|
29
|
+
"""Verify ParametricDSPEngine executes with zero model dependencies."""
|
|
30
|
+
with tts.load(engine="dsp", language="ko") as engine:
|
|
31
|
+
assert isinstance(engine.synth_engine, ParametricDSPEngine)
|
|
32
|
+
res = engine.synthesize("DSP 포먼트 엔진 단독 구동 테스트입니다.")
|
|
33
|
+
assert res.duration_sec > 0.3
|
|
34
|
+
assert res.model_name == "parametric-formant-dsp"
|
|
35
|
+
|
|
36
|
+
def test_gateway_auto_routing_to_dsp():
|
|
37
|
+
"""Verify TTSEngine defaults to ParametricDSPEngine in auto mode when no model file is given."""
|
|
38
|
+
with tts.load(engine="auto", language="ko") as engine:
|
|
39
|
+
assert isinstance(engine.synth_engine, ParametricDSPEngine)
|
|
40
|
+
res = engine.synthesize("자동 라우팅 테스트입니다.")
|
|
41
|
+
assert res.duration_sec > 0.3
|
|
42
|
+
|
|
43
|
+
def test_gateway_explicit_onnx_mode_requires_model():
|
|
44
|
+
"""Verify tts.load(engine='onnx') fails fast if no model file is given."""
|
|
45
|
+
with pytest.raises(TTSModelLoadError):
|
|
46
|
+
with tts.load(engine="onnx", language="ko") as engine:
|
|
47
|
+
engine.synthesize("신경망 테스트")
|
|
48
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Strict Dual Routing & Zero-Fallback Fail-Fast Verification for termux-tts.
|
|
3
|
+
Tests:
|
|
4
|
+
1. Explicit Vulkan mode (--device vulkan) -> Strict Fail-Fast on unavailable systems.
|
|
5
|
+
2. Auto mode (--device auto) -> Auto-detection and seamless transition to CPU.
|
|
6
|
+
3. Explicit CPU mode (--device cpu) -> Pure CPU execution.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import termux_tts as tts
|
|
11
|
+
from termux_tts.exceptions import VulkanInitializationError, TTSInferenceError
|
|
12
|
+
from termux_tts.engine import load
|
|
13
|
+
|
|
14
|
+
def test_explicit_cpu_mode():
|
|
15
|
+
with load(language="ko", device="cpu") as engine:
|
|
16
|
+
res = engine.synthesize("CPU 전용 모드 테스트입니다.")
|
|
17
|
+
assert "CPU" in res.backend
|
|
18
|
+
assert res.duration_sec > 0.3
|
|
19
|
+
print(f"\n[PASS CPU MODE] Backend: {res.backend}")
|
|
20
|
+
|
|
21
|
+
def test_auto_routing_mode():
|
|
22
|
+
with load(language="ko", device="auto") as engine:
|
|
23
|
+
res = engine.synthesize("자동 라우팅 모드 테스트입니다.")
|
|
24
|
+
assert res.backend in ["VULKAN_GPU", "ARM64_NEON_CPU", "X86_64_AVX2_CPU", "X86_CPU"]
|
|
25
|
+
print(f"\n[PASS AUTO ROUTING] Resolved Backend: {res.backend}")
|
|
26
|
+
|
|
27
|
+
def test_explicit_vulkan_fail_fast_when_disabled(monkeypatch):
|
|
28
|
+
from termux_tts import engine_dsp as dsp_mod
|
|
29
|
+
from termux_tts import engine_onnx as onnx_mod
|
|
30
|
+
from termux_tts import engine as eng_mod
|
|
31
|
+
|
|
32
|
+
class FakeDoctorDisabled:
|
|
33
|
+
is_vulkan_available = False
|
|
34
|
+
def probe_all(self):
|
|
35
|
+
return {"V0_LoaderOpen": "FAIL"}
|
|
36
|
+
|
|
37
|
+
monkeypatch.setattr(dsp_mod, "VulkanDoctor", FakeDoctorDisabled)
|
|
38
|
+
monkeypatch.setattr(onnx_mod, "VulkanDoctor", FakeDoctorDisabled)
|
|
39
|
+
monkeypatch.setattr(eng_mod, "VulkanDoctor", FakeDoctorDisabled)
|
|
40
|
+
|
|
41
|
+
# 1. Explicit Vulkan MUST RAISE VulkanInitializationError (No silent fallback!)
|
|
42
|
+
with pytest.raises(VulkanInitializationError) as exc_info:
|
|
43
|
+
load(language="ko", device="vulkan")
|
|
44
|
+
assert "FAIL-FAST" in str(exc_info.value)
|
|
45
|
+
assert "--device cpu" in str(exc_info.value)
|
|
46
|
+
print(f"\n[PASS FAIL-FAST GUARD] Correctly rejected with: {exc_info.value}")
|
|
47
|
+
|
|
48
|
+
# 2. Auto mode MUST gracefully route to CPU
|
|
49
|
+
with load(language="ko", device="auto") as engine:
|
|
50
|
+
res = engine.synthesize("Vulkan 없을 때 자동 CPU 전환 테스트.")
|
|
51
|
+
assert "CPU" in res.backend
|
|
52
|
+
print(f"\n[PASS AUTO DEGRADE TO CPU] Gracefully resolved: {res.backend}")
|