Skip to content

Commit f02ed32

Browse files
authored
Merge pull request #47 from CoffeeMethod/BugFix
Basically fixed three bugs and then built a test suite.
2 parents 6146ed0 + f2f84ba commit f02ed32

25 files changed

Lines changed: 1475 additions & 3 deletions

.github/workflows/tests.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
# kokoro_engine.py imports the Windows-only `winsound` module
11+
# unconditionally, so the suite can only run on Windows.
12+
runs-on: windows-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.11"
19+
20+
- name: Install dependencies
21+
run: |
22+
pip install -r requirements.txt
23+
pip install -r requirements-test.txt
24+
25+
- name: Run fast test suite
26+
run: pytest
27+
# Runs the mocked-pipeline suite only (pytest.ini already sets
28+
# `-m "not integration"` by default). No eSpeak NG or model
29+
# download needed. The real-synthesis integration suite
30+
# (`pytest -m integration tests/integration`) is intentionally
31+
# left out of CI - it's slow and pulls model weights.

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,31 @@ https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e
9191
- Click "Preview Audio" to hear a short sample.
9292
- Click "Start Generation" (or "Start Real-time JIT") to begin.
9393

94+
## Running Tests
95+
96+
The project has a `pytest` suite under `tests/` covering both `gui.py` and `kokoro_engine.py`. Because `kokoro_engine.py` imports the Windows-only `winsound` module unconditionally, **the suite only runs on Windows.**
97+
98+
1. **Install test dependencies** (on top of `requirements.txt`):
99+
```bash
100+
pip install -r requirements-test.txt
101+
```
102+
103+
2. **Run the fast suite** (default):
104+
```bash
105+
pytest
106+
```
107+
This mocks the Kokoro pipeline, so it runs in seconds with no model download and no eSpeak NG required. Caching is disabled by default in every test except `tests/test_caching.py`.
108+
109+
3. **Run the integration suite** (opt-in, real synthesis):
110+
```bash
111+
pytest -m integration tests/integration -s
112+
```
113+
Uses the real Kokoro pipeline, so it needs eSpeak NG on `PATH` (see Prerequisites) and downloads model weights on first use. It skips automatically if `espeak-ng` isn't found. Since real synthesis can't be verified automatically, each test speaks a short, self-describing sample naming the voice/mode and writes it to `tests/output/<timestamp>/.../*_transcript.txt` next to the generated `.wav` — listen to the audio and compare against the transcript to confirm it sounds right. The `-s` flag also prints the same text to the terminal as each test runs.
114+
115+
### CI
116+
117+
There's no CI workflow configured in this repo yet. A minimal one only needs to run step 2 above (`pytest`) on a `windows-latest` runner after installing `requirements.txt` + `requirements-test.txt` — the fast suite needs no eSpeak NG or model download, so it's safe to run on every push/PR. The integration suite is slow and pulls model weights, so it's better left as a manual/opt-in job rather than part of the default pipeline.
118+
94119
## Technologies Used
95120
96121
- **[Kokoro](https://github.com/hexgrad/kokoro):** The core TTS engine.

gui.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -599,8 +599,10 @@ def save_fx_preset_dialog(self):
599599

600600
def load_fx_preset(self, name):
601601
if name == "Select FX Preset...": return
602-
603-
fpath = os.path.join(FX_PRESETS_DIR, f"{name}.json")
602+
603+
safe_name = os.path.basename(name)
604+
if not safe_name: return
605+
fpath = os.path.join(FX_PRESETS_DIR, f"{safe_name}.json")
604606
if os.path.exists(fpath):
605607
try:
606608
with open(fpath, "r", encoding="utf-8") as f:

kokoro_engine.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,8 @@ def parse_multispeaker_text(self, text):
462462
Returns a list of (speaker_name, fx_name, text_segment)
463463
"""
464464
# Regex to find [Name]: or [Name:FX]:
465-
pattern = r"\[([^\]]+)\]:\s*"
465+
466+
pattern = r"\[([^\]\n]{1,100})\]:\s*"
466467
matches = list(re.finditer(pattern, text))
467468

468469
if not matches:

pytest.ini

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[pytest]
2+
testpaths = tests
3+
markers =
4+
integration: real KPipeline/torch/espeak-ng synthesis tests (slow, skipped by default)
5+
addopts = -m "not integration" --strict-markers

requirements-test.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pytest>=8.0

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
"""
2+
Shared fixtures for the KokoroGUI test suite.
3+
4+
Policy: every test that generates a config dict should build it through
5+
`make_config`, which defaults `caching=False`. Only tests/test_caching.py
6+
is allowed to override that to True (enforced by
7+
tests/test_meta_caching_policy.py). This keeps caching off by default
8+
without every test having to remember to pass it explicitly.
9+
"""
10+
import os
11+
import re
12+
import sys
13+
import time
14+
import threading
15+
import concurrent.futures
16+
import subprocess
17+
from pathlib import Path
18+
from types import SimpleNamespace
19+
from unittest.mock import MagicMock
20+
21+
import numpy as np
22+
import pytest
23+
import torch
24+
25+
import kokoro_engine
26+
from kokoro_engine import KokoroEngine
27+
28+
# On some Windows Store ("WindowsApps") Python installs, Tcl/Tk's own
29+
# init.tcl discovery intermittently fails against the package-virtualized
30+
# path when many Tk() roots are created/destroyed across a test session
31+
# (each GUI test builds a real TTSApp). Pointing TCL_LIBRARY/TK_LIBRARY at
32+
# the known-good path once avoids repeated, occasionally-flaky rediscovery.
33+
_tcl_dir = os.path.join(sys.base_prefix, "tcl", "tcl8.6")
34+
_tk_dir = os.path.join(sys.base_prefix, "tcl", "tk8.6")
35+
if os.path.isdir(_tcl_dir):
36+
os.environ.setdefault("TCL_LIBRARY", _tcl_dir)
37+
if os.path.isdir(_tk_dir):
38+
os.environ.setdefault("TK_LIBRARY", _tk_dir)
39+
40+
# One shared timestamp per pytest invocation, mirroring gui.py's
41+
# self.timecode_format = "%Y%m%d%H%M%S" convention (gui.py:96).
42+
_RUN_TS = time.strftime("%Y%m%d%H%M%S")
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# Engine-level fixtures
47+
# ---------------------------------------------------------------------------
48+
49+
@pytest.fixture
50+
def isolated_dirs(tmp_path, monkeypatch):
51+
"""Redirect kokoro_engine's module-level storage dirs into tmp_path."""
52+
custom_voices = tmp_path / "custom_voices"
53+
cache_dir = tmp_path / "cache"
54+
out_dir = tmp_path / "out"
55+
for d in (custom_voices, cache_dir, out_dir):
56+
d.mkdir()
57+
monkeypatch.setattr(kokoro_engine, "CUSTOM_VOICES_DIR", str(custom_voices))
58+
monkeypatch.setattr(kokoro_engine, "CACHE_DIR", str(cache_dir))
59+
return SimpleNamespace(custom_voices=custom_voices, cache_dir=cache_dir, out_dir=out_dir)
60+
61+
62+
@pytest.fixture
63+
def engine(isolated_dirs, monkeypatch):
64+
# Never touch the real audio device from a test.
65+
monkeypatch.setattr(kokoro_engine, "winsound", MagicMock())
66+
e = KokoroEngine()
67+
yield e
68+
e.worker.stop()
69+
70+
71+
@pytest.fixture
72+
def real_engine(isolated_dirs, monkeypatch):
73+
"""Real, unmocked KokoroEngine for tests/integration's opt-in real-pipeline
74+
tests. Identical to `engine` (isolated custom_voices/cache dirs, mocked
75+
winsound so playback never touches the real audio device) but never
76+
combined with `fake_pipeline` - get_thread_pipeline/KPipeline resolve to
77+
the real kokoro.KPipeline, so synthesis actually runs torch + espeak-ng."""
78+
monkeypatch.setattr(kokoro_engine, "winsound", MagicMock())
79+
e = KokoroEngine()
80+
yield e
81+
e.worker.stop()
82+
83+
84+
class FakePipeline:
85+
"""Mimics kokoro.KPipeline's calling convention without any model/espeak-ng."""
86+
87+
def __init__(self, lang_code="a", segment_duration_s=0.05, sr=24000):
88+
self.lang_code = lang_code
89+
self.voices = {}
90+
self._sr = sr
91+
self._dur = segment_duration_s
92+
93+
def __call__(self, text, voice=None, speed=1.0, split_pattern=r"\n+"):
94+
try:
95+
parts = [t.strip() for t in re.split(split_pattern, text) if t.strip()]
96+
except re.error:
97+
parts = []
98+
if not parts:
99+
parts = [text]
100+
n = max(1, int(self._sr * self._dur))
101+
for p in parts:
102+
audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(n) / self._sr)).astype(np.float32)
103+
yield p, "", audio
104+
105+
def load_voice(self, name):
106+
return torch.zeros(510, 1, 256)
107+
108+
109+
@pytest.fixture
110+
def fake_pipeline(monkeypatch):
111+
fp = FakePipeline()
112+
monkeypatch.setattr(kokoro_engine, "get_thread_pipeline", lambda lang_code="a": fp)
113+
monkeypatch.setattr(kokoro_engine, "KPipeline", lambda lang_code="a": fp)
114+
return fp
115+
116+
117+
@pytest.fixture
118+
def callback_recorder(engine):
119+
rec = SimpleNamespace(statuses=[], progresses=[], finished=threading.Event())
120+
engine.on_status = lambda msg, is_err: rec.statuses.append((msg, is_err))
121+
engine.on_progress = lambda *a: rec.progresses.append(a)
122+
engine.on_finish = lambda: rec.finished.set()
123+
return rec
124+
125+
126+
def wait_for_finish(rec, timeout=30):
127+
assert rec.finished.wait(timeout), "engine.on_finish was never called within timeout"
128+
129+
130+
@pytest.fixture
131+
def make_config(isolated_dirs):
132+
def _make(**overrides):
133+
cfg = {
134+
"voice": "af_heart",
135+
"speed": 1.0,
136+
"lang_code": "a",
137+
"split_pattern": r"\n+",
138+
"out_dir": str(isolated_dirs.out_dir),
139+
"filename": "output",
140+
"time_id": "0",
141+
"format": "wav",
142+
"num_threads": 1,
143+
"combine": True,
144+
"separate": True,
145+
"export_subtitles": False,
146+
"caching": False, # hard default OFF - see module docstring
147+
"lexicon": {},
148+
}
149+
cfg.update(overrides)
150+
return cfg
151+
return _make
152+
153+
154+
@pytest.fixture
155+
def timestamped_output_dir(request):
156+
"""
157+
tests/output/<run-timestamp>/<slugified-nodeid>/ - for tests whose generated
158+
audio should persist for manual inspection (real-pipeline integration tests,
159+
and a couple of "leaves_inspectable_output" smoke tests). Not used by
160+
throwaway unit tests, which use tmp_path/isolated_dirs instead.
161+
"""
162+
slug = re.sub(r"[^A-Za-z0-9_-]+", "_", request.node.nodeid)
163+
d = Path(__file__).parent / "output" / _RUN_TS / slug
164+
d.mkdir(parents=True, exist_ok=True)
165+
return d
166+
167+
168+
def espeak_available():
169+
# kokoro_engine never shells out to an `espeak-ng` CLI - phonemization
170+
# goes through misaki -> phonemizer's EspeakWrapper, pointed at the DLL
171+
# and data dir that the `espeakng_loader` package bundles/resolves
172+
# (see misaki/espeak.py). That's the actual runtime dependency, so
173+
# check for it directly instead of probing PATH for a binary the app
174+
# doesn't use.
175+
try:
176+
import espeakng_loader
177+
return (
178+
os.path.isfile(espeakng_loader.get_library_path())
179+
and os.path.isdir(espeakng_loader.get_data_path())
180+
)
181+
except Exception:
182+
return False
183+
184+
185+
# ---------------------------------------------------------------------------
186+
# GUI-level fixtures
187+
# ---------------------------------------------------------------------------
188+
189+
class StubEngine:
190+
"""Drop-in replacement for KokoroEngine used by GUI tests - never touches
191+
the real Kokoro pipeline/model."""
192+
193+
def __init__(self):
194+
self.pipeline = object() # truthy - passes the "engine still initializing" gate
195+
self.worker = SimpleNamespace(run_coro=MagicMock(return_value=concurrent.futures.Future()))
196+
self.cancel_event = threading.Event()
197+
self.on_progress = None
198+
self.on_status = None
199+
self.on_finish = None
200+
self.init_pipeline_async = MagicMock(return_value=None)
201+
self.start_conversion = MagicMock()
202+
self.start_jit_conversion = MagicMock()
203+
self.generate_preview = MagicMock()
204+
self.mix_voices = MagicMock()
205+
self.extract_text_from_file = MagicMock(return_value="")
206+
self.cancel = MagicMock()
207+
208+
209+
@pytest.fixture
210+
def tts_app(tmp_path, monkeypatch):
211+
import gui
212+
import tkinter
213+
214+
monkeypatch.chdir(tmp_path)
215+
monkeypatch.setattr(gui, "CONFIG_FILE", str(tmp_path / "config.json"))
216+
monkeypatch.setattr(gui, "PRESETS_DIR", str(tmp_path / "presets"))
217+
monkeypatch.setattr(gui, "FX_PRESETS_DIR", str(tmp_path / "presets" / "fx"))
218+
monkeypatch.setattr(gui, "KokoroEngine", StubEngine)
219+
monkeypatch.setattr(gui, "messagebox", MagicMock())
220+
monkeypatch.setattr(gui, "filedialog", MagicMock())
221+
(tmp_path / "custom_voices").mkdir()
222+
223+
# Creating many real Tk() interpreters across a test session intermittently
224+
# hits the same WindowsApps init.tcl read glitch as above - retry a few
225+
# times rather than failing the whole test on a transient hiccup.
226+
app = None
227+
last_err = None
228+
for _ in range(5):
229+
try:
230+
app = gui.TTSApp()
231+
break
232+
except tkinter.TclError as e:
233+
last_err = e
234+
time.sleep(0.2)
235+
if app is None:
236+
raise last_err
237+
238+
yield app
239+
app.destroy()

tests/integration/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)