From 9d110e5a53f064415f8954969df277cb13db38df Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Fri, 21 Aug 2026 18:45:42 -0600 Subject: [PATCH 1/3] gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 033192e..64ee911 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,4 @@ cython_debug/ .pypirc /audio_output/ /ROADMAP.md +/CLAUDE.md From ca79ad4c34092d6e4dccc76026d3c6e22d60d53c Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Fri, 21 Aug 2026 19:05:38 -0600 Subject: [PATCH 2/3] Replace winsound with cross-platform sounddevice playback kokoro_engine.py and gui.py unconditionally imported the Windows-only winsound module for preview/JIT playback, forcing the whole app (and CI) onto windows-latest. Add playback.py, a small sounddevice + soundfile wrapper (play/stop), and swap it in at all four call sites: - kokoro_engine.py: cancel()'s SND_PURGE -> playback.stop(), and the JIT playback loop's blocking PlaySound -> playback.play(path, True) - gui.py: the two fire-and-forget preview buttons -> playback.play(path) playback.py degrades to a no-op if sounddevice can't find the system PortAudio library, so machines without libportaudio2 installed don't crash on import. Also: - Add sounddevice to requirements.txt - Repoint conftest.py's engine/real_engine fixtures to mock kokoro_engine.playback instead of kokoro_engine.winsound - Add ubuntu-latest to the CI matrix (installs libportaudio2 for sounddevice, xvfb for the Tk-based GUI tests, runs under xvfb-run) - Update README's testing/CI notes, which had drifted out of date --- .github/workflows/tests.yml | 32 +++++++++++++++++++++++------ README.md | 8 ++++++-- gui.py | 6 +++--- kokoro_engine.py | 8 ++++---- playback.py | 41 +++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + tests/conftest.py | 6 +++--- 7 files changed, 84 insertions(+), 18 deletions(-) create mode 100644 playback.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e643f64..c984fc6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,9 +7,19 @@ 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 @@ -17,15 +27,25 @@ jobs: 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 diff --git a/README.md b/README.md index e45285a..6ca8e1e 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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 diff --git a/gui.py b/gui.py index 6040ea4..b47e7cd 100644 --- a/gui.py +++ b/gui.py @@ -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 @@ -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: @@ -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") diff --git a/kokoro_engine.py b/kokoro_engine.py index e89cf17..cd3764d 100644 --- a/kokoro_engine.py +++ b/kokoro_engine.py @@ -22,7 +22,7 @@ import warnings import re import json -import winsound +import playback import tempfile from kokoro import KPipeline @@ -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 @@ -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: diff --git a/playback.py b/playback.py new file mode 100644 index 0000000..e822979 --- /dev/null +++ b/playback.py @@ -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() diff --git a/requirements.txt b/requirements.txt index 32e7ca5..ea0d45b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ packaging scipy pedalboard soundfile==0.13.1 +sounddevice torch==2.13.0 customtkinter packaging \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index feb55e5..712fbd3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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() @@ -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() From c8fb9cd7415216f915b1b29eb860331da917e229 Mon Sep 17 00:00:00 2001 From: CoffeeMethod Date: Fri, 21 Aug 2026 19:07:59 -0600 Subject: [PATCH 3/3] Updated gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 64ee911..eb6e99e 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,4 @@ cython_debug/ /audio_output/ /ROADMAP.md /CLAUDE.md +/tests/output/