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
32 changes: 26 additions & 6 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,45 @@ on:

jobs:
test:
# kokoro_engine.py imports the Windows-only `winsound` module
# unconditionally, so the suite can only run on Windows.
runs-on: windows-latest
# Playback goes through playback.py (sounddevice/PortAudio) instead of
# the Windows-only `winsound` module, so kokoro_engine.py/gui.py no
# longer force Windows-only. ubuntu-latest additionally needs:
# - libportaudio2 (system PortAudio lib `sounddevice` dlopens)
# - Xvfb (the GUI suite builds real Tk windows - tests/conftest.py's
# `tts_app` fixture - which needs a display on headless Linux)
# macos-latest is left out for now (unverified) - see ROADMAP.md's
# "CI expansion" item.
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

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

- name: Install PortAudio + Xvfb (Linux)
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libportaudio2 xvfb

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

- name: Run fast test suite
run: pytest
- name: Run fast test suite (Linux)
if: runner.os == 'Linux'
run: xvfb-run -a 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.
# left out of CI - it's slow and pulls model weights. Wrapped in
# xvfb-run so the Tk-based GUI tests have a display to attach to.

- name: Run fast test suite (Windows)
if: runner.os != 'Linux'
run: pytest
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,5 @@ cython_debug/
.pypirc
/audio_output/
/ROADMAP.md
/CLAUDE.md
/tests/output/
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ A modern, high-quality Text-to-Speech (TTS) application built with Python, featu

https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e

## New in 3.2.0

- **Cross-Platform Audio Playback:** Preview and JIT playback now go through `sounddevice`/`soundfile` instead of the Windows-only `winsound` module, removing a hard Windows dependency from `kokoro_engine.py`/`gui.py`.

## New in 3.1.0

- **JIT (Just-In-Time) Generation:** Real-time audio streaming. Start listening to your text immediately as it's being generated.
Expand Down Expand Up @@ -93,7 +97,7 @@ https://github.com/user-attachments/assets/c75e7141-5d73-40f4-b182-d4f5bc49ad1e

## 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.**
The project has a `pytest` suite under `tests/` covering both `gui.py` and `kokoro_engine.py`. Playback no longer forces Windows-only (see [`playback.py`](playback.py)), and CI (`.github/workflows/tests.yml`) now runs the suite on both `windows-latest` and `ubuntu-latest` (the Linux leg installs `libportaudio2` for `sounddevice` and runs under `xvfb-run` since the GUI tests build real Tk windows). `macos-latest` isn't set up yet.

1. **Install test dependencies** (on top of `requirements.txt`):
```bash
Expand All @@ -114,7 +118,7 @@ The project has a `pytest` suite under `tests/` covering both `gui.py` and `koko

### 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.
[.github/workflows/tests.yml](.github/workflows/tests.yml) runs step 2 above (`pytest`) on push/PR against `windows-latest` and `ubuntu-latest` (the Linux leg additionally installs `libportaudio2` and runs under `xvfb-run`, as noted above) 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 intentionally left out as a manual/opt-in run rather than part of the default pipeline.

## Technologies Used

Expand Down
6 changes: 3 additions & 3 deletions gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import time
import json
import re
import winsound
import playback
import customtkinter as ctk
from tkinter import filedialog, messagebox
import threading
Expand Down Expand Up @@ -754,7 +754,7 @@ def _on_done(future):
success, err = future.result()
if success:
self.after(0, lambda: self.mix_status_label.configure(text="Playing preview...", text_color="green"))
winsound.PlaySound(tmp_audio_path, winsound.SND_FILENAME | winsound.SND_ASYNC)
playback.play(tmp_audio_path)
else:
self.after(0, lambda: self.mix_status_label.configure(text=f"Preview failed: {err}", text_color="red"))
except Exception as e:
Expand Down Expand Up @@ -1622,7 +1622,7 @@ def _ui_update():
success = future.result()
if success:
self.status_label.configure(text="Playing preview...", text_color="green")
winsound.PlaySound(tmp_path, winsound.SND_FILENAME | winsound.SND_ASYNC)
playback.play(tmp_path)
self.after(3000, lambda: self.status_label.configure(text="Ready", text_color="gray"))
else:
self.status_label.configure(text="Preview failed.", text_color="red")
Expand Down
8 changes: 4 additions & 4 deletions kokoro_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import warnings
import re
import json
import winsound
import playback
import tempfile
from kokoro import KPipeline

Expand Down Expand Up @@ -733,8 +733,8 @@ def start_conversion(self, text, config):
def cancel(self):
self.cancel_event.set()
try:
# Stop any current winsound playback immediately
winsound.PlaySound(None, winsound.SND_PURGE)
# Stop any current playback immediately
playback.stop()
except Exception:
pass

Expand Down Expand Up @@ -853,7 +853,7 @@ async def playback_loop():
self.on_progress(percent, elapsed, "--:--", f"Playing: {clean_snip}")

# Play audio (Synchronously in thread)
await asyncio.to_thread(winsound.PlaySound, item['path'], winsound.SND_FILENAME)
await asyncio.to_thread(playback.play, item['path'], True)

played_segments.append(item)
if item in generated_but_unplayed:
Expand Down
41 changes: 41 additions & 0 deletions playback.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Cross-platform audio playback for preview buttons and JIT streaming.

Wraps `sounddevice` (PortAudio) instead of the Windows-only `winsound`
module, so preview/JIT playback works on Windows, macOS, and Linux.

On Linux, `sounddevice` needs the system PortAudio shared library
(`libportaudio2` / `portaudio19-dev`) installed. If it's missing, importing
`sounddevice` raises OSError - we catch that and degrade to a no-op instead
of crashing import of kokoro_engine/gui on machines without it.
"""
import soundfile as sf

try:
import sounddevice as sd
AVAILABLE = True
except OSError:
sd = None
AVAILABLE = False


def play(path: str, blocking: bool = False) -> None:
"""Play an audio file.

blocking=True waits for playback to finish (used to pace the JIT
playback loop, matching the old `winsound.PlaySound(..., SND_FILENAME)`
behavior). blocking=False fires and forgets (used by preview buttons,
matching the old `SND_ASYNC` behavior).
"""
if not AVAILABLE:
return
data, samplerate = sf.read(path, dtype='float32')
sd.play(data, samplerate)
if blocking:
sd.wait()


def stop() -> None:
"""Stop any currently playing audio immediately (old SND_PURGE)."""
if not AVAILABLE:
return
sd.stop()
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ packaging
scipy
pedalboard
soundfile==0.13.1
sounddevice
torch==2.13.0
customtkinter
packaging
6 changes: 3 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def isolated_dirs(tmp_path, monkeypatch):
@pytest.fixture
def engine(isolated_dirs, monkeypatch):
# Never touch the real audio device from a test.
monkeypatch.setattr(kokoro_engine, "winsound", MagicMock())
monkeypatch.setattr(kokoro_engine, "playback", MagicMock())
e = KokoroEngine()
yield e
e.worker.stop()
Expand All @@ -72,10 +72,10 @@ def engine(isolated_dirs, monkeypatch):
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
playback so audio 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())
monkeypatch.setattr(kokoro_engine, "playback", MagicMock())
e = KokoroEngine()
yield e
e.worker.stop()
Expand Down
Loading