Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Tests

on:
push:
branches: [main]
pull_request:

jobs:
test:
# kokoro_engine.py imports the Windows-only `winsound` module
# unconditionally, so the suite can only run on Windows.
runs-on: windows-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install dependencies
run: |
pip install -r requirements.txt
pip install -r requirements-test.txt

- name: Run fast test suite
run: pytest
# Runs the mocked-pipeline suite only (pytest.ini already sets
# `-m "not integration"` by default). No eSpeak NG or model
# download needed. The real-synthesis integration suite
# (`pytest -m integration tests/integration`) is intentionally
# left out of CI - it's slow and pulls model weights.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,31 @@ https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e
- Click "Preview Audio" to hear a short sample.
- Click "Start Generation" (or "Start Real-time JIT") to begin.

## Running Tests

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.**

1. **Install test dependencies** (on top of `requirements.txt`):
```bash
pip install -r requirements-test.txt
```

2. **Run the fast suite** (default):
```bash
pytest
```
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`.

3. **Run the integration suite** (opt-in, real synthesis):
```bash
pytest -m integration tests/integration -s
```
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.

### CI

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.

## Technologies Used

- **[Kokoro](https://github.com/hexgrad/kokoro):** The core TTS engine.
Expand Down
6 changes: 4 additions & 2 deletions gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,8 +599,10 @@ def save_fx_preset_dialog(self):

def load_fx_preset(self, name):
if name == "Select FX Preset...": return

fpath = os.path.join(FX_PRESETS_DIR, f"{name}.json")

safe_name = os.path.basename(name)
if not safe_name: return
fpath = os.path.join(FX_PRESETS_DIR, f"{safe_name}.json")
if os.path.exists(fpath):
try:
with open(fpath, "r", encoding="utf-8") as f:
Expand Down
3 changes: 2 additions & 1 deletion kokoro_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,8 @@ def parse_multispeaker_text(self, text):
Returns a list of (speaker_name, fx_name, text_segment)
"""
# Regex to find [Name]: or [Name:FX]:
pattern = r"\[([^\]]+)\]:\s*"

pattern = r"\[([^\]\n]{1,100})\]:\s*"
matches = list(re.finditer(pattern, text))

if not matches:
Expand Down
5 changes: 5 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
markers =
integration: real KPipeline/torch/espeak-ng synthesis tests (slow, skipped by default)
addopts = -m "not integration" --strict-markers
1 change: 1 addition & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pytest>=8.0
Empty file added tests/__init__.py
Empty file.
239 changes: 239 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
"""
Shared fixtures for the KokoroGUI test suite.

Policy: every test that generates a config dict should build it through
`make_config`, which defaults `caching=False`. Only tests/test_caching.py
is allowed to override that to True (enforced by
tests/test_meta_caching_policy.py). This keeps caching off by default
without every test having to remember to pass it explicitly.
"""
import os
import re
import sys
import time
import threading
import concurrent.futures
import subprocess
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock

import numpy as np
import pytest
import torch

import kokoro_engine
from kokoro_engine import KokoroEngine

# On some Windows Store ("WindowsApps") Python installs, Tcl/Tk's own
# init.tcl discovery intermittently fails against the package-virtualized
# path when many Tk() roots are created/destroyed across a test session
# (each GUI test builds a real TTSApp). Pointing TCL_LIBRARY/TK_LIBRARY at
# the known-good path once avoids repeated, occasionally-flaky rediscovery.
_tcl_dir = os.path.join(sys.base_prefix, "tcl", "tcl8.6")
_tk_dir = os.path.join(sys.base_prefix, "tcl", "tk8.6")
if os.path.isdir(_tcl_dir):
os.environ.setdefault("TCL_LIBRARY", _tcl_dir)
if os.path.isdir(_tk_dir):
os.environ.setdefault("TK_LIBRARY", _tk_dir)

# One shared timestamp per pytest invocation, mirroring gui.py's
# self.timecode_format = "%Y%m%d%H%M%S" convention (gui.py:96).
_RUN_TS = time.strftime("%Y%m%d%H%M%S")


# ---------------------------------------------------------------------------
# Engine-level fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def isolated_dirs(tmp_path, monkeypatch):
"""Redirect kokoro_engine's module-level storage dirs into tmp_path."""
custom_voices = tmp_path / "custom_voices"
cache_dir = tmp_path / "cache"
out_dir = tmp_path / "out"
for d in (custom_voices, cache_dir, out_dir):
d.mkdir()
monkeypatch.setattr(kokoro_engine, "CUSTOM_VOICES_DIR", str(custom_voices))
monkeypatch.setattr(kokoro_engine, "CACHE_DIR", str(cache_dir))
return SimpleNamespace(custom_voices=custom_voices, cache_dir=cache_dir, out_dir=out_dir)


@pytest.fixture
def engine(isolated_dirs, monkeypatch):
# Never touch the real audio device from a test.
monkeypatch.setattr(kokoro_engine, "winsound", MagicMock())
e = KokoroEngine()
yield e
e.worker.stop()


@pytest.fixture
def real_engine(isolated_dirs, monkeypatch):
"""Real, unmocked KokoroEngine for tests/integration's opt-in real-pipeline
tests. Identical to `engine` (isolated custom_voices/cache dirs, mocked
winsound so playback never touches the real audio device) but never
combined with `fake_pipeline` - get_thread_pipeline/KPipeline resolve to
the real kokoro.KPipeline, so synthesis actually runs torch + espeak-ng."""
monkeypatch.setattr(kokoro_engine, "winsound", MagicMock())
e = KokoroEngine()
yield e
e.worker.stop()


class FakePipeline:
"""Mimics kokoro.KPipeline's calling convention without any model/espeak-ng."""

def __init__(self, lang_code="a", segment_duration_s=0.05, sr=24000):
self.lang_code = lang_code
self.voices = {}
self._sr = sr
self._dur = segment_duration_s

def __call__(self, text, voice=None, speed=1.0, split_pattern=r"\n+"):
try:
parts = [t.strip() for t in re.split(split_pattern, text) if t.strip()]
except re.error:
parts = []
if not parts:
parts = [text]
n = max(1, int(self._sr * self._dur))
for p in parts:
audio = (0.1 * np.sin(2 * np.pi * 220 * np.arange(n) / self._sr)).astype(np.float32)
yield p, "", audio

def load_voice(self, name):
return torch.zeros(510, 1, 256)


@pytest.fixture
def fake_pipeline(monkeypatch):
fp = FakePipeline()
monkeypatch.setattr(kokoro_engine, "get_thread_pipeline", lambda lang_code="a": fp)
monkeypatch.setattr(kokoro_engine, "KPipeline", lambda lang_code="a": fp)
return fp


@pytest.fixture
def callback_recorder(engine):
rec = SimpleNamespace(statuses=[], progresses=[], finished=threading.Event())
engine.on_status = lambda msg, is_err: rec.statuses.append((msg, is_err))
engine.on_progress = lambda *a: rec.progresses.append(a)
engine.on_finish = lambda: rec.finished.set()
return rec


def wait_for_finish(rec, timeout=30):
assert rec.finished.wait(timeout), "engine.on_finish was never called within timeout"


@pytest.fixture
def make_config(isolated_dirs):
def _make(**overrides):
cfg = {
"voice": "af_heart",
"speed": 1.0,
"lang_code": "a",
"split_pattern": r"\n+",
"out_dir": str(isolated_dirs.out_dir),
"filename": "output",
"time_id": "0",
"format": "wav",
"num_threads": 1,
"combine": True,
"separate": True,
"export_subtitles": False,
"caching": False, # hard default OFF - see module docstring
"lexicon": {},
}
cfg.update(overrides)
return cfg
return _make


@pytest.fixture
def timestamped_output_dir(request):
"""
tests/output/<run-timestamp>/<slugified-nodeid>/ - for tests whose generated
audio should persist for manual inspection (real-pipeline integration tests,
and a couple of "leaves_inspectable_output" smoke tests). Not used by
throwaway unit tests, which use tmp_path/isolated_dirs instead.
"""
slug = re.sub(r"[^A-Za-z0-9_-]+", "_", request.node.nodeid)
d = Path(__file__).parent / "output" / _RUN_TS / slug
d.mkdir(parents=True, exist_ok=True)
return d


def espeak_available():
# kokoro_engine never shells out to an `espeak-ng` CLI - phonemization
# goes through misaki -> phonemizer's EspeakWrapper, pointed at the DLL
# and data dir that the `espeakng_loader` package bundles/resolves
# (see misaki/espeak.py). That's the actual runtime dependency, so
# check for it directly instead of probing PATH for a binary the app
# doesn't use.
try:
import espeakng_loader
return (
os.path.isfile(espeakng_loader.get_library_path())
and os.path.isdir(espeakng_loader.get_data_path())
)
except Exception:
return False


# ---------------------------------------------------------------------------
# GUI-level fixtures
# ---------------------------------------------------------------------------

class StubEngine:
"""Drop-in replacement for KokoroEngine used by GUI tests - never touches
the real Kokoro pipeline/model."""

def __init__(self):
self.pipeline = object() # truthy - passes the "engine still initializing" gate
self.worker = SimpleNamespace(run_coro=MagicMock(return_value=concurrent.futures.Future()))
self.cancel_event = threading.Event()
self.on_progress = None
self.on_status = None
self.on_finish = None
self.init_pipeline_async = MagicMock(return_value=None)
self.start_conversion = MagicMock()
self.start_jit_conversion = MagicMock()
self.generate_preview = MagicMock()
self.mix_voices = MagicMock()
self.extract_text_from_file = MagicMock(return_value="")
self.cancel = MagicMock()


@pytest.fixture
def tts_app(tmp_path, monkeypatch):
import gui
import tkinter

monkeypatch.chdir(tmp_path)
monkeypatch.setattr(gui, "CONFIG_FILE", str(tmp_path / "config.json"))
monkeypatch.setattr(gui, "PRESETS_DIR", str(tmp_path / "presets"))
monkeypatch.setattr(gui, "FX_PRESETS_DIR", str(tmp_path / "presets" / "fx"))
monkeypatch.setattr(gui, "KokoroEngine", StubEngine)
monkeypatch.setattr(gui, "messagebox", MagicMock())
monkeypatch.setattr(gui, "filedialog", MagicMock())
(tmp_path / "custom_voices").mkdir()

# Creating many real Tk() interpreters across a test session intermittently
# hits the same WindowsApps init.tcl read glitch as above - retry a few
# times rather than failing the whole test on a transient hiccup.
app = None
last_err = None
for _ in range(5):
try:
app = gui.TTSApp()
break
except tkinter.TclError as e:
last_err = e
time.sleep(0.2)
if app is None:
raise last_err

yield app
app.destroy()
Empty file added tests/integration/__init__.py
Empty file.
Loading
Loading