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