|
| 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() |
0 commit comments