From ffbf2f21be47107d2b28e475adf714900d09185b Mon Sep 17 00:00:00 2001 From: RenatoXSR Date: Thu, 6 Aug 2026 15:56:27 -0300 Subject: [PATCH] [epub-tts] Vendor aedocw backends and add adapter subclasses - Add git submodules under src/vendor for the aedocw repositories: - epub2tts, epub2tts-edge, epub2tts-chatterbox, epub2tts-kokoro - Add adapter modules in src/epub_tts to run vendored packages in isolated venvs: - vendor_utils.py (creates/uses per-repo .venv and installs package) - epub2tts.py, epub2tts_edge.py, epub2tts_chatterbox.py, epub2tts_kokoro.py - Add aedocw_backend.py providing AedocwBackend and concrete subclasses that dispatch to the vendored adapters when selected via the CLI. - Add tts_edge shim for backward compatibility. - Register new backends in src/epub_tts/__main__.py and invoke the selected backend at runtime. - Update pyproject.toml to exclude vendored packages from setuptools package discovery. This change vendors upstream tools while providing thin adapters that invoke their CLIs inside isolated virtualenvs, and integrates them into the epub-tts backend registry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitmodules | 12 +++ pyproject.toml | 1 + src/epub_tts/__main__.py | 29 ++++-- src/epub_tts/aedocw_backend.py | 149 ++++++++++++++++++++++++++++ src/epub_tts/epub2tts.py | 74 ++++++++++++++ src/epub_tts/epub2tts_chatterbox.py | 61 ++++++++++++ src/epub_tts/epub2tts_edge.py | 52 ++++++++++ src/epub_tts/epub2tts_kokoro.py | 52 ++++++++++ src/epub_tts/tts_edge.py | 13 +++ src/epub_tts/vendor_utils.py | 66 ++++++++++++ src/vendor/epub2tts | 1 + src/vendor/epub2tts-chatterbox | 1 + src/vendor/epub2tts-edge | 1 + src/vendor/epub2tts-kokoro | 1 + 14 files changed, 507 insertions(+), 6 deletions(-) create mode 100644 .gitmodules create mode 100644 src/epub_tts/aedocw_backend.py create mode 100644 src/epub_tts/epub2tts.py create mode 100644 src/epub_tts/epub2tts_chatterbox.py create mode 100644 src/epub_tts/epub2tts_edge.py create mode 100644 src/epub_tts/epub2tts_kokoro.py create mode 100644 src/epub_tts/tts_edge.py create mode 100644 src/epub_tts/vendor_utils.py create mode 160000 src/vendor/epub2tts create mode 160000 src/vendor/epub2tts-chatterbox create mode 160000 src/vendor/epub2tts-edge create mode 160000 src/vendor/epub2tts-kokoro diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a49cc23 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "src/vendor/epub2tts"] + path = src/vendor/epub2tts + url = https://github.com/aedocw/epub2tts +[submodule "src/vendor/epub2tts-edge"] + path = src/vendor/epub2tts-edge + url = https://github.com/aedocw/epub2tts-edge +[submodule "src/vendor/epub2tts-chatterbox"] + path = src/vendor/epub2tts-chatterbox + url = https://github.com/aedocw/epub2tts-chatterbox +[submodule "src/vendor/epub2tts-kokoro"] + path = src/vendor/epub2tts-kokoro + url = https://github.com/aedocw/epub2tts-kokoro diff --git a/pyproject.toml b/pyproject.toml index 509507f..8f93971 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ epub-tts = "epub_tts.__main__:main" # SETUPTOOLS [tool.setuptools.packages.find] where = ["src"] +exclude = ["vendor*"] [tool.setuptools.dynamic] #version = {attr = "epub_tts.__version__"} diff --git a/src/epub_tts/__main__.py b/src/epub_tts/__main__.py index c582c27..ba51ce5 100644 --- a/src/epub_tts/__main__.py +++ b/src/epub_tts/__main__.py @@ -7,13 +7,19 @@ import sys from argparse import ArgumentParser -from epub_tts import tts_backend -from epub_tts import tts_edge +from epub_tts import aedocw_backend, tts_backend, tts_edge backend = { # keys are the backend names, values are the corresponding TTS classes "default": tts_backend.GenericTTSBackend, "edge": tts_edge.TTSEdge, + # aedocw-backed implementations — these expect the corresponding + # package "console scripts"/entrypoints to be available in the + # environment (or an explicit backend_cmd to be provided). + "epub2tts": aedocw_backend.AedocwEpub2TTS, + "epub2tts-edge": aedocw_backend.AedocwEpub2TTSEdge, + "epub2tts-chatterbox": aedocw_backend.AedocwChatterbox, + "epub2tts-kokoro": aedocw_backend.AedocwKokoro, } @@ -29,10 +35,13 @@ def main(args=None): p.add_argument("-o", "--output", help="Output audio file", required=True) p.add_argument("-l", "--language", help="Language to use for TTS") p.add_argument("-v", "--voice", help="Voice to use for TTS") - p.add_argument("-b", "--backend", - help="Backend to use for TTS", - default="default", - choices=backend.keys()) + p.add_argument( + "-b", + "--backend", + help="Backend to use for TTS", + default="default", + choices=backend.keys(), + ) args = p.parse_args(args or sys.argv[1:]) print( f"Converting {args.input} to {args.output} using voice " @@ -40,6 +49,14 @@ def main(args=None): f"backend {args.backend}, ..." ) + backend_cls = backend[args.backend] + backend_instance = backend_cls( + backend_cmd=None, + language=args.language, + voice=args.voice, + ) + backend_instance.run(args.input, args.output) + if __name__ == "__main__": sys.exit(main()) diff --git a/src/epub_tts/aedocw_backend.py b/src/epub_tts/aedocw_backend.py new file mode 100644 index 0000000..4be64b3 --- /dev/null +++ b/src/epub_tts/aedocw_backend.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa +# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) +"""Backends for the aedocw collection of EPUB->TTS repositories. + +This module defines subclasses of GenericTTSBackend that provide sane +default backend command names for the aedocw repositories available on +GitHub: epub2tts, epub2tts-edge, epub2tts-chatterbox and epub2tts-kokoro. + +The vendored packages are run in isolated virtual environments when the +corresponding console script is not available in the current environment. +""" + +from typing import Optional + +from . import epub2tts as epub2tts_adapter +from . import epub2tts_chatterbox as epub2tts_chatterbox_adapter +from . import epub2tts_edge as epub2tts_edge_adapter +from . import epub2tts_kokoro as epub2tts_kokoro_adapter +from .tts_backend import GenericTTSBackend + + +class AedocwBackend(GenericTTSBackend): + """Generic aedocw backend wrapper. + + Parameters: + repo: repository name (one of the keys in _CMD_MAP) + backend_cmd: explicit command/executable to use (overrides repo mapping) + language: optional language code + voice: optional voice name + + The GenericTTSBackend stores the default command name in self.backend_cmd, + but the vendored adapters may still run the package in their own venv if + the console script is not present. + """ + + _CMD_MAP = { + "epub2tts": "epub2tts", + "epub2tts-edge": "epub2tts-edge", + "epub2tts-chatterbox": "epub2tts-chatterbox", + "epub2tts-kokoro": "epub2tts-kokoro", + } + + _RUNNER_MAP = { + "epub2tts": epub2tts_adapter.run, + "epub2tts-edge": epub2tts_edge_adapter.run, + "epub2tts-chatterbox": epub2tts_chatterbox_adapter.run, + "epub2tts-kokoro": epub2tts_kokoro_adapter.run, + } + + def __init__( + self, + repo: str = "epub2tts", + backend_cmd: Optional[str] = None, + language: Optional[str] = None, + voice: Optional[str] = None, + ): + if backend_cmd is None: + backend_cmd = self._CMD_MAP.get(repo, "epub2tts") + super().__init__(backend_cmd=backend_cmd, language=language, voice=voice) + self.repo = repo + + def run(self, input_source, output_dest): + runner = self._RUNNER_MAP.get(self.repo) + if runner is None: + raise ValueError(f"Unknown repository backend: {self.repo}") + + return runner( + input_source, + output_dest, + language=self.language, + voice=self.voice, + backend_cmd=self.backend_cmd, + ) + + +class AedocwEpub2TTS(AedocwBackend): + """Backend configured for github.com/aedocw/epub2tts.""" + + def __init__( + self, + backend_cmd: Optional[str] = None, + language: Optional[str] = None, + voice: Optional[str] = None, + ): + super().__init__( + repo="epub2tts", backend_cmd=backend_cmd, language=language, voice=voice + ) + + +class AedocwEpub2TTSEdge(AedocwBackend): + """Backend configured for github.com/aedocw/epub2tts-edge.""" + + def __init__( + self, + backend_cmd: Optional[str] = None, + language: Optional[str] = None, + voice: Optional[str] = None, + ): + super().__init__( + repo="epub2tts-edge", + backend_cmd=backend_cmd, + language=language, + voice=voice, + ) + + +class AedocwChatterbox(AedocwBackend): + """Backend configured for github.com/aedocw/epub2tts-chatterbox.""" + + def __init__( + self, + backend_cmd: Optional[str] = None, + language: Optional[str] = None, + voice: Optional[str] = None, + ): + super().__init__( + repo="epub2tts-chatterbox", + backend_cmd=backend_cmd, + language=language, + voice=voice, + ) + + +class AedocwKokoro(AedocwBackend): + """Backend configured for github.com/aedocw/epub2tts-kokoro.""" + + def __init__( + self, + backend_cmd: Optional[str] = None, + language: Optional[str] = None, + voice: Optional[str] = None, + ): + super().__init__( + repo="epub2tts-kokoro", + backend_cmd=backend_cmd, + language=language, + voice=voice, + ) + + +__all__ = [ + "AedocwBackend", + "AedocwEpub2TTS", + "AedocwEpub2TTSEdge", + "AedocwChatterbox", + "AedocwKokoro", +] diff --git a/src/epub_tts/epub2tts.py b/src/epub_tts/epub2tts.py new file mode 100644 index 0000000..8996d57 --- /dev/null +++ b/src/epub_tts/epub2tts.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa +# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) +"""Adapter for the vendored github.com/aedocw/epub2tts repository.""" + +import subprocess +from pathlib import Path +from typing import Optional + +from .vendor_utils import build_run_command + +SUPPORTED_AUDIO_FORMATS = { + ".m4b": "m4b", + ".wav": "wav", + ".flac": "flac", +} + + +def _normalize_path(path_input: Path | str) -> Path: + return Path(path_input) + + +def _build_command( + input_source: Path, + output_dest: Path, + language: Optional[str] = None, + voice: Optional[str] = None, + backend_cmd: Optional[str] = None, +) -> list[str]: + command = build_run_command("epub2tts", "epub2tts", backend_cmd=backend_cmd) + command.append(str(input_source)) + + if language: + command.extend(["--language", language]) + + if voice: + command.extend(["--speaker", voice]) + + output_format = SUPPORTED_AUDIO_FORMATS.get(output_dest.suffix.lower(), "m4b") + if output_format != "m4b": + command.extend(["--audioformat", output_format]) + + return command + + +def run( + input_source: Path | str, + output_dest: Path | str, + language: Optional[str] = None, + voice: Optional[str] = None, + backend_cmd: Optional[str] = None, +) -> subprocess.CompletedProcess: + input_path = _normalize_path(input_source) + output_path = _normalize_path(output_dest) + output_path.parent.mkdir(parents=True, exist_ok=True) + + command = _build_command( + input_path, + output_path, + language=language, + voice=voice, + backend_cmd=backend_cmd, + ) + + completed = subprocess.run(command, cwd=str(input_path.parent), check=True) + + generated_path = input_path.with_suffix( + SUPPORTED_AUDIO_FORMATS.get(output_path.suffix.lower(), "m4b") + ) + if generated_path != output_path: + generated_path.replace(output_path) + + return completed diff --git a/src/epub_tts/epub2tts_chatterbox.py b/src/epub_tts/epub2tts_chatterbox.py new file mode 100644 index 0000000..8ecfc04 --- /dev/null +++ b/src/epub_tts/epub2tts_chatterbox.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa +# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) +"""Adapter for the vendored github.com/aedocw/epub2tts-chatterbox repository.""" + +import subprocess +from pathlib import Path +from typing import Optional + +from .vendor_utils import build_run_command + +DEFAULT_SAMPLE = "none" + + +def _normalize_path(path_input: Path | str) -> Path: + return Path(path_input) + + +def _build_command( + input_source: Path, + sample: Optional[str] = None, + backend_cmd: Optional[str] = None, +) -> list[str]: + command = build_run_command( + "epub2tts-chatterbox", + "epub2tts_chatterbox", + backend_cmd=backend_cmd, + ) + command.append(str(input_source)) + if sample and sample != DEFAULT_SAMPLE: + command.extend(["--sample", sample]) + return command + + +def run( + input_source: Path | str, + output_dest: Path | str, + voice: Optional[str] = None, + backend_cmd: Optional[str] = None, +) -> subprocess.CompletedProcess: + input_path = _normalize_path(input_source) + output_path = _normalize_path(output_dest) + output_path.parent.mkdir(parents=True, exist_ok=True) + + sample_text = DEFAULT_SAMPLE + cwd = input_path.parent + if voice: + voice_path = Path(voice) + sample_text = voice_path.name + if voice_path.parent != Path(""): + cwd = str(voice_path.parent) + + command = _build_command(input_path, sample=sample_text, backend_cmd=backend_cmd) + completed = subprocess.run(command, cwd=cwd, check=True) + + generated_path = Path(cwd) / f"{input_path.stem} ({sample_text}).m4b" + if generated_path != output_path: + generated_path.replace(output_path) + + return completed diff --git a/src/epub_tts/epub2tts_edge.py b/src/epub_tts/epub2tts_edge.py new file mode 100644 index 0000000..ef4ae18 --- /dev/null +++ b/src/epub_tts/epub2tts_edge.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa +# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) +"""Adapter for the vendored github.com/aedocw/epub2tts-edge repository.""" + +import subprocess +from pathlib import Path +from typing import Optional + +from .vendor_utils import build_run_command + +DEFAULT_SPEAKER = "en-US-AndrewNeural" + + +def _normalize_path(path_input: Path | str) -> Path: + return Path(path_input) + + +def _build_command( + input_source: Path, + voice: Optional[str] = None, + backend_cmd: Optional[str] = None, +) -> list[str]: + command = build_run_command( + "epub2tts-edge", "epub2tts_edge", backend_cmd=backend_cmd + ) + command.append(str(input_source)) + if voice: + command.extend(["--speaker", voice]) + return command + + +def run( + input_source: Path | str, + output_dest: Path | str, + voice: Optional[str] = None, + backend_cmd: Optional[str] = None, +) -> subprocess.CompletedProcess: + input_path = _normalize_path(input_source) + output_path = _normalize_path(output_dest) + output_path.parent.mkdir(parents=True, exist_ok=True) + + speaker = voice or DEFAULT_SPEAKER + command = _build_command(input_path, voice=speaker, backend_cmd=backend_cmd) + completed = subprocess.run(command, cwd=str(input_path.parent), check=True) + + generated_path = input_path.parent / f"{input_path.stem} ({speaker}).m4b" + if generated_path != output_path: + generated_path.replace(output_path) + + return completed diff --git a/src/epub_tts/epub2tts_kokoro.py b/src/epub_tts/epub2tts_kokoro.py new file mode 100644 index 0000000..5099cd9 --- /dev/null +++ b/src/epub_tts/epub2tts_kokoro.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa +# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) +"""Adapter for the vendored github.com/aedocw/epub2tts-kokoro repository.""" + +import subprocess +from pathlib import Path +from typing import Optional + +from .vendor_utils import build_run_command + +DEFAULT_SPEAKER = "af_heart" + + +def _normalize_path(path_input: Path | str) -> Path: + return Path(path_input) + + +def _build_command( + input_source: Path, + voice: Optional[str] = None, + backend_cmd: Optional[str] = None, +) -> list[str]: + command = build_run_command( + "epub2tts-kokoro", "epub2tts_kokoro", backend_cmd=backend_cmd + ) + command.append(str(input_source)) + if voice: + command.extend(["--speaker", voice]) + return command + + +def run( + input_source: Path | str, + output_dest: Path | str, + voice: Optional[str] = None, + backend_cmd: Optional[str] = None, +) -> subprocess.CompletedProcess: + input_path = _normalize_path(input_source) + output_path = _normalize_path(output_dest) + output_path.parent.mkdir(parents=True, exist_ok=True) + + speaker = voice or DEFAULT_SPEAKER + command = _build_command(input_path, voice=speaker, backend_cmd=backend_cmd) + completed = subprocess.run(command, cwd=str(input_path.parent), check=True) + + generated_path = input_path.parent / f"{input_path.stem} ({speaker}).m4b" + if generated_path != output_path: + generated_path.replace(output_path) + + return completed diff --git a/src/epub_tts/tts_edge.py b/src/epub_tts/tts_edge.py new file mode 100644 index 0000000..45d6a31 --- /dev/null +++ b/src/epub_tts/tts_edge.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa +# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) +"""Compatibility shim for the edge backend in epub-tts.""" + +from .aedocw_backend import AedocwEpub2TTSEdge + + +class TTSEdge(AedocwEpub2TTSEdge): + """Backward-compatible edge backend wrapper.""" + + pass diff --git a/src/epub_tts/vendor_utils.py b/src/epub_tts/vendor_utils.py new file mode 100644 index 0000000..9ab2d73 --- /dev/null +++ b/src/epub_tts/vendor_utils.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa +# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html) +"""Utilities for running vendored EPUB->TTS repositories in isolated venvs.""" + +import subprocess +import sys +import venv +from pathlib import Path +from typing import Optional + + +def get_vendor_root() -> Path: + return Path(__file__).resolve().parents[1] / "vendor" + + +def get_repo_path(repo_name: str) -> Path: + repo_path = get_vendor_root() / repo_name + if not repo_path.exists(): + raise FileNotFoundError( + f"Vendored repository '{repo_name}' not found under {get_vendor_root()}" + ) + return repo_path + + +def get_venv_path(repo_name: str) -> Path: + return get_repo_path(repo_name) / ".venv" + + +def get_venv_python(repo_name: str) -> Path: + venv_path = get_venv_path(repo_name) + python_executable = ( + venv_path / "Scripts" / "python.exe" + if sys.platform == "win32" + else venv_path / "bin" / "python" + ) + if not python_executable.exists(): + raise FileNotFoundError( + f"Python executable not found in virtualenv at {python_executable}" + ) + return python_executable + + +def ensure_venv(repo_name: str) -> Path: + repo_path = get_repo_path(repo_name) + venv_path = get_venv_path(repo_name) + if not venv_path.exists(): + venv.EnvBuilder(with_pip=True).create(venv_path) + python_executable = get_venv_python(repo_name) + subprocess.run( + [str(python_executable), "-m", "pip", "install", "-e", str(repo_path)], + check=True, + ) + return get_venv_python(repo_name) + + +def build_run_command( + repo_name: str, + module_name: str, + backend_cmd: Optional[str] = None, +) -> list[str]: + if backend_cmd is None or backend_cmd == repo_name: + python_executable = ensure_venv(repo_name) + return [str(python_executable), "-m", module_name] + return [backend_cmd] diff --git a/src/vendor/epub2tts b/src/vendor/epub2tts new file mode 160000 index 0000000..286d129 --- /dev/null +++ b/src/vendor/epub2tts @@ -0,0 +1 @@ +Subproject commit 286d12975f43861218a5c3c8737b30430e34718e diff --git a/src/vendor/epub2tts-chatterbox b/src/vendor/epub2tts-chatterbox new file mode 160000 index 0000000..5dead2e --- /dev/null +++ b/src/vendor/epub2tts-chatterbox @@ -0,0 +1 @@ +Subproject commit 5dead2e9e58e993427f281c9f2a6a743235a01f1 diff --git a/src/vendor/epub2tts-edge b/src/vendor/epub2tts-edge new file mode 160000 index 0000000..6fb7a0f --- /dev/null +++ b/src/vendor/epub2tts-edge @@ -0,0 +1 @@ +Subproject commit 6fb7a0f125188c0ff961a8db260f10221eb32c3c diff --git a/src/vendor/epub2tts-kokoro b/src/vendor/epub2tts-kokoro new file mode 160000 index 0000000..dd27e57 --- /dev/null +++ b/src/vendor/epub2tts-kokoro @@ -0,0 +1 @@ +Subproject commit dd27e5721ad3f3839331c7dfd059a4404ce76c2a