Author SHA1 Message Date
renatoxsr 2b26381a84 Atualizar defaults.env 2026-09-23 12:00:42 -03:00
renatoxsr 2d34e175bd elapsed time adjustment 2026-08-12 15:17:52 -03:00
renatoxsr b45321a19f logger and spantrack.main() working great 2026-08-12 12:32:16 -03:00
renatoxsr ea16f87fc9 spantrack interface ok 2026-08-12 09:46:34 -03:00
renatoxsr 2f9b95e095 . 2026-08-12 08:58:40 -03:00
renatoxsr 1266aef52a Merge branch 'dev' 2026-08-12 08:56:27 -03:00
renatoxsr da8e4ee602 .. 2026-08-12 08:50:39 -03:00
renatoxsr 8a0f66ebc6 spantrack 2026-08-12 08:47:49 -03:00
renatoxsr 067319f7ef log vs config vs app (DIRTY) 2026-08-11 10:10:52 -03:00
renatoxsr 2167e9b801 Debug logger 2026-08-10 18:32:29 -03:00
renatoxsr 67404c71df Logger 2026-08-10 18:20:07 -03:00
renatoxsr d087b64320 Config interface ok 2026-08-10 16:37:59 -03:00
renatoxsr 2777b34026 tts-kokoro engine underway 2026-08-07 19:00:45 -03:00
renatoxsr a1f564a051 Kokoro working, updated interface 2026-08-07 14:53:53 -03:00
renatoxsr 28ffc1820b working backend 2026-08-07 10:16:22 -03:00
renatoxsr 3553426a72 fix: lambda 2026-08-07 00:13:00 -03:00
renatoxsr b1c7a42172 Unified aedocw backends 2026-08-07 00:03:19 -03:00
renatoxsr da8940bdd4 Merge pull request 'Merge/create epub into main' (#2) from merge/create-epub-into-main into main
Reviewed-on: #2
2026-08-06 18:26:05 -03:00
renatoxsr f2c66a1876 ... 2026-08-06 18:20:54 -03:00
renatoxsr b515b8b722 [epub-tts] Merge agents/create-epub-sample-submodule into main (merge branch) 2026-08-06 18:19:44 -03:00
29 changed files with 5612 additions and 584 deletions
+4 -1
View File
@@ -137,6 +137,8 @@ ENV/
env.bak/
venv.bak/
.tmp-samples/
.tmp*
.venv*
# Spyder project settings
.spyderproject
@@ -223,7 +225,8 @@ $RECYCLE.BIN/
.LSOverride
# Icon must end with two \r
Icon␍
Icon
# Thumbnails
._*
+1
View File
@@ -0,0 +1 @@
3.11
+38 -1
View File
@@ -2,9 +2,46 @@
Python project to gather several projects that convert EPUB files to audiobooks (M4B files, mostly), with different TTS backends.
# TL;DR
## Setup
```sh
git clone https://git.silveirarosa.com/renatoxsr/epub-tts
cd .\epub-tts\
pyenv local 3.11
python3 -m venv .venv
git submodule update --init --recursive
```
## Install on Windows
```pwsh
.\.venv\Scripts\python.exe -m pip install -e '.[dev]'
.\.venv\Scripts\download-epub-minimal.exe
.\.venv\Scripts\download-moby-dick.exe
.\.venv\Scripts\download-moby-dick-media-overlays.exe
.\.venv\Scripts\epub-tts.exe -i .\.tmp-samples\minimal.epub -o .\.tmp-m4b\minimal.m4b -b epub2tts-edge
```
## Install on POSIX
```sh
.\.venv\bin\python -m pip install -e '.[dev]'
.\.venv\bin\download-epub-minimal
.\.venv\bin\download-moby-dick
.\.venv\bin\download-moby-dick-media-overlays
.\.venv\bin\epub-tts -i ./.tmp-samples/minimal.epub -o ./.tmp-m4b/minimal.m4b -b epub2tts-edge
```
# Requirements
Recent Python (>=3.11), pip (>=22.3), some sort of virtula environment recommended (such as the builtin venv package), and a Rust compatible platform (avoid embedded systems which do not have pre-built binaries for the compiled requirements, e.g., Android's Termux try to build everything from sources and fails miserably when doing so).
- `ffmpeg` available (your distribution's version should be ok)
- Recent Python (>=3.11), pip (>=22.3), and some sort of virtual environment recommended (such as the builtin venv package).
- a Rust compatible platform. Avoid embedded systems which do not have pre-built
binaries for the compiled requirements (e.g., Android's Termux try to build
everything from sources and fails miserably when doing so).
- For GPU use, make sure you are installing compatible versions of
[`pytorch`](https://pytorch.org/get-started/locally/) and adjust your backends
installation method, for instance, run `pip install --force-reinstall` with the correct args
inside the relevant virtual environments.
# Installation
+2
View File
@@ -0,0 +1,2 @@
HF_TOKEN=
NLTK_DATA=
+18
View File
@@ -27,7 +27,25 @@ classifiers = [
]
dependencies = [
"audioop-lts; python_version >= '3.13'",
"beautifulsoup4",
"colorlog",
"dotenv",
"ebooklib",
"kokoro>=0.9.4",
"load-dotenv>=0.1.0",
"lxml",
"mutagen",
"nltk",
"numpy", # --index-url https://download.pytorch.org/whl/cu132
"pillow", # --index-url https://download.pytorch.org/whl/cu132
"pyyaml",
"pydub",
"soundfile",
"torch", # --index-url https://download.pytorch.org/whl/cu132
"torchaudio", # --index-url https://download.pytorch.org/whl/cu132
"torchcodec", # PyTorch 2.9+ # --index-url https://download.pytorch.org/whl/cu132
"tqdm", # --index-url https://download.pytorch.org/whl/cu132
]
[project.optional-dependencies]
+5
View File
@@ -12,3 +12,8 @@ try:
except PackageNotFoundError:
# package is not installed
pass
#from .logger import logger, loglevel_map, get_logger
#from .utils import DataDict, PathLike, path
#from .app import App
+92 -34
View File
@@ -3,23 +3,38 @@
# 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)
import sys
<<<<<<< HEAD
from pathlib import Path
from argparse import ArgumentParser
from epub_tts import aedocw_backend, tts_backend, tts_edge
# Pip packages
from dotenv import load_dotenv
backend = {
# Local imports
from . import tts_aedocw, tts_generic
#from . import tts_kokoro
from . import log, PathLike
from .utils import check_env
# Setup env
load_dotenv() # load environment variables from .env file if present
# Globals
WHICH = ["ffmpeg"] # Neded in $PATH
BACKENDS = {
# keys are the backend names, values are the corresponding TTS classes
"default": tts_backend.GenericTTSBackend,
"edge": tts_edge.TTSEdge,
"default": tts_generic.GenericTTSBackend,
"edge": tts_aedocw.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,
"epub2tts": tts_aedocw.AedocwEpub2TTS,
"epub2tts-edge": tts_aedocw.AedocwEpub2TTSEdge,
"epub2tts-chatterbox": tts_aedocw.AedocwChatterbox,
"epub2tts-kokoro": tts_aedocw.AedocwKokoro,
"generic-epub2tts": tts_aedocw.Epub2TTS,
#"kokoro": tts_kokoro.KokoroBackend,
}
@@ -31,32 +46,75 @@ def main(args=None):
action="version",
version=f"%(prog)s {__import__('epub_tts').__version__}",
)
p.add_argument("-i", "--input", help="Input EPUB file", required=True)
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(),
)
args = p.parse_args(args or sys.argv[1:])
print(
f"Converting {args.input} to {args.output} using voice "
f"{args.voice}, language {args.language}, "
f"backend {args.backend}, ..."
)
# Input options and processing
p.add_argument("-i", "--input",
nargs="+", required=True,
help="Input EPUB file")
p.add_argument("-r", "--replace", action="append", nargs=2,
help="Replace text in the intermediate output. "
"Specify pairs of old_text new_text. "
"Can be used multiple times.")
p.add_argument("--check-env",action="store_true",
help="Check the runtime environment and exit")
p.add_argument("-b", "--backend", default="default",
choices=BACKENDS.keys(),
help="Backend to use for TTS")
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)
# Output options
p.add_argument("-o", "--output",
help="Output audio file", required=True)
p.add_argument("-c", "--cover",
help="Path to cover image to embed or use "
"for output metadata")
# Speech options
p.add_argument("-l", "--language",
help="Language to use for TTS (see your "
"backend's documentation for available voices)")
p.add_argument("-v", "--voice",
help="Voice to use for TTS (see your backend's "
"documentation for available voices)")
p.add_argument("--speed", type=float, default=1.0,
help="Playback speed multiplier (default: 1.0) "
"(not all backends support this)")
p.add_argument("--short-pause", type=int, default=None,
help="Short pause duration in milliseconds between "
"phrases or sentences (not all backends support this)")
p.add_argument("--long-pause", type=int, default=None,
help="Long pause duration in milliseconds between "
"sections or paragraphs (not all backends support this)")
p.add_argument("--notitles", action="store_true",
help="Do not read chapter titles")
# Parse
args = p.parse_args(args or sys.argv[1:])
setattr(args, 'replace_map',
{old: new for old, new in args.replace} if args.replace else None)
log(args)
# Execute actions
if args.check_env:
check_env()
engine = BACKENDS[args.backend](**vars(args))
if len(args.input) > 1 and not output_dest.is_dir():
log(f"Output must be a directory when multiple input files are provided", level="error")
sys.exit(1)
for file in args.input:
log(file)
if not Path(file).exists():
log(f"Input file {file} does not exist", level="error")
sys.exit(1)
output_dest = Path(args.output)
engine.run(
file, output_dest,
**vars(args),
)
=======
from .cli import cli
>>>>>>> dev
if __name__ == "__main__":
sys.exit(main())
sys.exit(cli())
-149
View File
@@ -1,149 +0,0 @@
#!/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",
]
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html)
"""config.py
The industry-standard hierarchy for application configuration is:
1. Command-Line Arguments (argparse) (Highest priority – explicit runtime overrides).
2. Environment Variables (os.environ) (Medium priority – deployment or session-specific configuration).
3. Configuration Files (e.g., .env, .ini, .yaml files)
4. Hardcoded Defaults (Lowest priority – fallback safety net)
"""
# stdlib
from argparse import ArgumentParser
from collections import UserDict
from pathlib import Path
from typing import Optional
import logging
import os
import re
import shutil
import sys
# Pip packages
from dotenv import load_dotenv
# Local imports
from . import tts_aedocw, tts_generic, tts_kokoro
from . import logger, PathLike, loglevel_map
from . import DataDict
class App():
WHICH = ["ffmpeg"] # Needed in $PATH
BACKENDS = {
# keys are the backend names, values are the corresponding TTS classes
"default": tts_generic.GenericTTSBackend,
"edge": tts_aedocw.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": tts_aedocw.AedocwEpub2TTS,
"epub2tts-edge": tts_aedocw.AedocwEpub2TTSEdge,
"epub2tts-chatterbox": tts_aedocw.AedocwChatterbox,
"epub2tts-kokoro": tts_aedocw.AedocwKokoro,
"generic-epub2tts": tts_aedocw.Epub2TTS,
"kokoro": tts_kokoro.KokoroBackend,
}
def __init__(self,
input_str: Optional[str|Path|list[str|Path]],
output_str: Optional[str|Path],
backend: str,
):
# Validate and sanitize output
self.output_path = App.path(self.output)
if len(self.input) > 1 and not self.output_path.is_dir():
self.logger.error("Output must be a directory when multiple input files are provided. Invalid: '%s'", self.output_path)
sys.exit(1)
del self._data["output"]
# Validate and sanitize input
self.input_files = {}
self.input_dirs = []
self.logger.info("Paths provided as input: %d", len(self.input))
for i, input_str in enumerate(self.input):
self.logger.info("[Input %d] %s", i+1, input_str)
input_path = App.path(input_str)
if not input_path.exists():
self.logger.warning("Skipping path (do not exist): '%s'.", input_path)
continue
if input_path.is_dir():
self.input_dir.append(input_path)
self.logger.info("Added directory to queue: '%s'", input_path)
else:
self.input_files[input_str] = {"path":input_path}
self.input_files[input_str]["filename"] = self.input_files[input_str]["path"].name
self.input_files[input_str]["stem"] = self.input_files[input_str]["path"].stem
self.input_files[input_str]["suffix"] = self.input_files[input_str]["path"].suffix
#self.input_files[input_str]["filetype"] = ebook_meta self.input_files[input_str]["path"]
self.logger.info("Added file to queue: %s", self.input_files[input_str])
del self._data["input"]
# Sanitize replace_map
self.logger.debug(self.replace)
if self.replace and len(self.replace) % 2 == 0:
self.replace_map = { old: new for old, new in self.replace}
del self.replace
# print args
self.logger.debug(self)
# Execute actions
if self._parsed_args.check_env:
self.check_env()
self.engine = App.BACKENDS[self.backend](self)
# Utility methods
def check_env(self, which=None):
"""Check anvironment for necessary binaries"""
if not which:
which = self.WHICH
if isinstance(which, str):
if " " in which:
which = which.split()
which = [which]
for bin in which:
if sys.platform == "win32":
p = shutil.which(bin + ".exe")
else:
p = shutil.which(bin)
if not p:
self.logger.error(f"%s is either NOT installed or "
"NOT in your system PATH.", bin)
return False
else:
self.logger.info(f"%s found at: %s", bin, p)
return True
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html)
"""Command-line interface for converting EPUB to audio using TTS.
This module provides a command-line interface (CLI) for converting EPUB files to audio using text-to-speech (TTS) backends. It allows users to specify the input EPUB file, output audio file, language, voice, and backend to use for TTS conversion.
usage: epub-tts [-h] [--version] -i INPUT -o OUTPUT [-l LANGUAGE]
[-v VOICE] [-b {default,edge,epub2tts,epub2tts-edge,epub2tts-chatterbox,epub2tts-kokoro}]
optional arguments:
-h, --help show this help message and exit
--version Show version and exit
-i INPUT, --input INPUT
Input EPUB file
-o OUTPUT, --output OUTPUT
Output audio file
-l LANGUAGE, --language LANGUAGE
Language to use for TTS
-v VOICE, --voice Voice to use for TTS
-b {default,edge,epub2tts,epub2tts-edge,epub2tts-chatterbox,epub2tts-kokoro}, --backend {default,edge,epub2tts,epub2tts-edge,epub2tts-chatterbox,epub2tts-kokoro}
Backend to use for TTS
"""
# stdlib
import os
import sys
from pathlib import Path
from argparse import ArgumentParser
# Local imports
from . import App
from . import logger, get_logger
# Globals/Defaults
DEFAULT_ENV_FILE=".env"
ENV_FILE_OVERRIDE=False
DEBUG = False
LOGLEVEL = "WARNING"
def get_parser():
p = ArgumentParser(description="Convert EPUB to audio using TTS")
p.add_argument(
"--version",
help="Show version and exit",
action="version",
version=f"%(prog)s {__import__('epub_tts').__version__}")
# General options
p.add_argument(
"--debug", dest="debug_level", action="store_true",
help="Enable debug logging and verbose diagnostics")
p.add_argument(
"--verbose", action="store_true",
help="Enable info-level logging")
p.add_argument(
"--loglevel",
type=str.upper, default=logging.INFO,
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set logging level, from most verbose to least verbose:")
p.add_argument(
"--logfile", default=None,
help="Write logs to the specified file")
# Input options and processing
p.add_argument(
"-i", "--input",
nargs="+", required=True,
help="Input EPUB file")
p.add_argument(
"-r", "--replace", action="append", nargs=2,
help="Replace text in the intermediate output. "
"Specify pairs of old_text new_text. "
"Can be used multiple times.")
p.add_argument(
"--check-env",action="store_true",
help="Check the runtime environment and exit")
p.add_argument(
"-b", "--backend", default="default",
choices=App.BACKENDS.keys(),
help="Backend to use for TTS")
# Output options
p.add_argument(
"-o", "--output",
help="Output audio file", required=True)
p.add_argument(
"-c", "--cover",
help="Path to cover image to embed or use "
"for output metadata")
# Speech options
p.add_argument(
"-l", "--language",
help="Language to use for TTS (see your "
"backend's documentation for available voices)")
p.add_argument(
"-v", "--voice",
help="Voice to use for TTS (see your backend's "
"documentation for available voices)")
p.add_argument(
"--speed", type=float, default=1.0,
help="Playback speed multiplier (default: 1.0) "
"(not all backends support this)")
p.add_argument(
"--short-pause", type=int, default=None,
help="Short pause duration in milliseconds between "
"phrases or sentences (not all backends support this)")
p.add_argument(
"--long-pause", type=int, default=None,
help="Long pause duration in milliseconds between "
"sections or paragraphs (not all backends support this)")
p.add_argument(
"--notitles", action="store_true",
help="Do not read chapter titles")
return p
def setup_app(cmdline:str|list[str]=None):
logger = get_logger()
parser = get_parser()
parsed_args = p.parse_args(cmdline)
if "env_file" not in self._data:
self._data["env_file"] = App.DEFAULT_ENV_FILE
self.logger.debug(self._data["env_file"])
# for path_flag in ["env_file"]:
# if not Path(self[path_flag]).exists():
# self.logger.warning(f"Could not read %s. Ignoring '%s' and using '%s'", path_flag, self[path_flag], App.DEFAULT_ENV_FILE)
# self[path_flag] = App["DEFAULT_" + path_flag.upper()]
# load .env/env_file
load_dotenv(self.env_file, override=os.environ.get("ENV_FILE_OVERRIDE",App.ENV_FILE_OVERRIDE))
# Now we can call argparser with correct defaults and logger/loglevel
# Get ArgumentParser and pased *args or read from sys.argv
self._parsed_args = self.get_parser().parse_args(args or sys.argv[1:])
for k,v in self._parsed_args.__dict__.items():
self[k] = v
def cli(args=None):
app = App()
app.engine(**vars(args))
app.engine.run(
app.file, app.output_dest,
**vars(args),
)
File diff suppressed because it is too large Load Diff
-74
View File
@@ -1,74 +0,0 @@
#!/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
-61
View File
@@ -1,61 +0,0 @@
#!/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
-52
View File
@@ -1,52 +0,0 @@
#!/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
-52
View File
@@ -1,52 +0,0 @@
#!/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
+261
View File
@@ -0,0 +1,261 @@
#!/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)
# ============================================================
# Logging
# ============================================================
from pathlib import Path
import enum
import logging
import os
import re
import sys
import pprint
import locale
import yaml
from typing import Any, Optional, Callable, Required
# pip packages
from dotenv import load_dotenv
import colorlog
logger = logging.getLogger(__name__)
loglevel_map = logging.getLevelNamesMapping()
# Globals
format_str = "[%(name)s][%(filename)s:%(lineno)04d][%(relativeCreated)s]::%(levelname).4s: (%(funcName)s) %(message)s"
# %(name)s Name of the logger (logging channel)
# %(levelno)s Numeric logging level for the message (DEBUG, INFO,
# WARNING, ERROR, CRITICAL)
# %(levelname)s Text logging level for the message ("DEBUG", "INFO",
# "WARNING", "ERROR", "CRITICAL")
# %(pathname)s Full pathname of the source file where the logging
# call was issued (if available)
# %(filename)s Filename portion of pathname
# %(module)s Module (name portion of filename)
# %(lineno)d Source line number where the logging call was issued
# (if available)
# %(funcName)s Function name
# %(created)f Time when the LogRecord was created (time.time_ns() / 1e9
# return value)
# %(asctime)s Textual time when the LogRecord was created
# %(msecs)d Millisecond portion of the creation time
# %(relativeCreated)d Time in milliseconds when the LogRecord was created,
# relative to the time the logging module was loaded
# (typically at application startup time)
# %(thread)d Thread ID (if available)
# %(threadName)s Thread name (if available)
# %(taskName)s Task name (if available)
# %(process)d Process ID (if available)
# %(processName)s Process name (if available)
# %(message)s The result of record.getMessage(), computed just as
# the record is emitted
# formatter = logging.Formatter(format_str)
# logging.basicConfig(
# # 50=CRITICAL/FATAL, 40=ERROR, 30=WARN/WARNING, 20=INFO, 10=DEBUG, 0=NOTSET
# # loglevel_map = logging.getLevelNamesMapping()
# level=logging.INFO,
# format=format_str,
# )
# True regex pattern:
#locale.nl_langinfo(locale.YESEXPR)
RE_TRUE = r"^[yY]|^[sS]|^[tT]|^[oO][nN]|1"
# False regex pattern: (use with caution!)
# locale.nl_langinfo(locale.NOEXPR)
RE_FALSE = r"^[nN]|^[fF]|^[oO][fF]+|0"
# None:
# matches any combination of word boundaries and whitespaces.
# \b cannot be used in a character range. See
RE_NONE = r"^\b*\s*\b*\s*$"
def user_bool(user_str: str, empty_is_none: bool = False, from_yaml=False) -> None|bool:
"""Use caution in this call, only if you are absolutely sure that user_str should be bool or none/empty"""
if from_yaml:
return yaml.YAMLObject().from_yaml(user_str)
if re.match(RE_TRUE, user_str):
return True
if re.match(RE_FALSE, user_str):
return False
if re.match(RE_NONE, user_str):
if empty_is_none:
return None
else:
return False
raise ValueError(user_str)
def get_env(env_name: str,
cast_type: Optional[Callable] = str,
default: Optional[str] = None,
) -> Any | str:
if not isinstance(cast_type, Callable):
raise ValueError(cast_type)
return cast_type(os.environ.get(env_name, default))
def get_logger(name):
return logging.getLogger(name)
def create_logger(name = __name__, loglevel: int = logging.INFO):
load_dotenv()
logging.captureWarnings(get_env("CAPTURE_WARNINGS", user_bool, "True"))
handler = colorlog.StreamHandler()
handler.setFormatter(colorlog.ColoredFormatter(
#"%(log_color)s%(levelname)-8s%(reset)s %(blue)s%(message)s",
f"%(log_color)s{format_str}%(reset)s",
log_colors={
'DEBUG': 'cyan',
'INFO': 'green',
'WARNING': 'orange',
'ERROR': 'red',
'CRITICAL': 'yellow',
}))
logger = colorlog.getLogger(name)
logger.addHandler(handler)
logger.setLevel(loglevel)
return logger
# Simple logger by printing
def print(message):
pp = pprint.PrettyPrinter(
indent=4,
width=os.get_terminal_size().columns,
compact=False,
depth=None
)
if isinstance(message, (dict, list, tuple, set)):
formatted = pp.pformat(message)
print(formatted.replace("', '", "',\n'"))
else:
pp.pprint(message)
def get_level(
cmdline: str|list[str] = None,
verbose: bool = False,
debug: bool = False,
quiet: bool = False,
loglevel: int = logging.INFO,
) -> int:
# Find lowest priority loglevel
if "--debug" in cmdline:
debug = True
if "--verbose" in cmdline:
verbose = True
if "--quiet" in cmdline:
quiet = True
if debug:
msg = ["Returning loglevel 'DEBUG'"]
if verbose or quiet:
msg.append(", ignoring other loglevel flags ")
if verbose and quiet:
msg.append(", ignoring other loglevel flags "
"(--verbose and --quiet) which are also set")
elif verbose:
msg.append("(--verbose), which is also set")
else:
msg.append("(--quiet), which is also set")
msg.append(".")
if not quiet:
logger.info("".join(msg))
return logging.DEBUG
if verbose:
if quiet:
return logging.INFO
#msg.append(", ignoring '--quiet' loglevel flag which was also set")
logger.info("Returning loglevel 'INFO' ('--verbose' flag was set).")
return logging.INFO
if quiet:
return loglevel
# msg = [f"Returning default loglevel '{loglevel_map[loglevel]}'."]
# logger.info("".join(msg))
# return loglevel
# logger.info("Returning loglevel 'DEBUG' and ignoring other loglevel flags ("
# f"{"--verbose" if "--verbose" in cmdline} is also set"
# f"{"--quiet" if "--quiet" in cmdline}"
# ")")
# return logging.DEBUG
# if "--verbose" in cmdline:
# verbose = True
# logger.info("Returning loglevel 'INFO'")
# return logging.INFO
# if "--quiet" in cmdline:
# logger.info("Returning loglevel 'ERROR'")
# return logging.ERROR
# return logging.DEBUG
# for i, a in enumerate(cmdline):
# if a == "--loglevel":
# loglevel = loglevel_map.get(cmdline[i+1], None)
# elif a.startswith("--loglevel="):
# loglevel = loglevel_map.get(a.replace("--loglevel=", ""), None)
# # lower = more priority
# if loglevel_map[loglevel] > loglevel_map["INFO"]:
# logger.debug("self.loglevel='%s'(%d) > INFO", loglevel, loglevel_map[loglevel])
# loglevel = "INFO"
# def get_logger(
# verbose:bool = False,
# debug:bool = False,
# quiet: bool = False,
# loglevel: str|int = logging.INFO,
# logfile: str|Path = None,
# ):
# """Initialize the package logger from CLI flags and defaults.
# Resolve the effective logging level from the class args (or sys.argv).
# --debug (or debug_level=True) takes priority, then --verbose (or verbose=True)
# is compared to --loglevel (or loglevel=) and the lower one is proritized.
# """
# config.logger.info("Setting loglevel to %s",config.loglevel)
# config.logger.setLevel(loglevel_map[config.loglevel])
# # Get logfile
# for i, a in enumerate(config._args_list):
# if a == "--logfile":
# config.logfile = config._args_list[i+1]
# elif a.startswith("--logfile="):
# config.logfile = a.replace("--logfile=", "")
# if "logfile" in config and config.logfile:
# if logfile:
# config.logger.warning("Overriding --logfile='%s' from setup_logger(logfile='%s')", config.logfile, logfile)
# config.logfile = logfile
# fh = logging.FileHandler(logfile)
# fh.setLevel(config.loglevel)
# fh.setFormatter(logger.formatter)
# config.logger.addHandler(fh)
# # if self.debug_level:
# # logger.info("Loglevel: DEBUG")
# # if self.loglevel:
# # logger.warning("Ignoring '--loglevel' flag because DEBUG/'--debug' was also set.")
# # if "--verbose" in self._args_list:
# # logger.warning("Ignoring '--verbose' flag because '--debug' was also set.")
# # self.verbose = False
# # if self.loglevel and self.verbose:
# # self.warning("Conflict: trying to --loglevel=%s (%d) and --verbose.", self.loglevel, loglevel_map[self.loglevel])
# # if loglevel_map[self.loglevel] < logging.INFO:
# # self.logger.info("Setting loglevel to %s (%d), which is more verbose than INFO.", self.loglevel, loglevel_map[self.loglevel])
# return config.logger
+111
View File
@@ -0,0 +1,111 @@
#!/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)
"""Postprocess audio files into an audiobook.
"""
import subprocess
# stdlib modules
import os
import sys
# pip installed packages
import numpy as np
import soundfile
import torch
from tqdm import tqdm
from kokoro import KPipeline
from ebooklib import epub
import soundfile as sf
from mutagen import mp4
from pydub import AudioSegment
from mutagen import mp4
# Local imports
from .tts_generic import GenericTTSBackend
from . import logger, PathLike
from .preprocess import preprocess_book
def generate_metadata(files, author, title, chapter_titles):
chap = 0
start_time = 0
with open("FFMETADATAFILE", "w") as file:
file.write(";FFMETADATA1\n")
file.write(f"ARTIST={author}\n")
file.write(f"ALBUM={title}\n")
file.write(f"TITLE={title}\n")
file.write("DESCRIPTION=Made with https://github.com/aedocw/epub2tts-kokoro\n")
for file_name in files:
duration = get_duration(file_name)
file.write("[CHAPTER]\n")
file.write("TIMEBASE=1/1000\n")
file.write(f"START={start_time}\n")
file.write(f"END={start_time + duration}\n")
file.write(f"title={chapter_titles[chap]}\n")
chap += 1
start_time += duration
def get_duration(file_path):
audio = AudioSegment.from_file(file_path)
duration_milliseconds = len(audio)
return duration_milliseconds
def make_m4b(files, sourcefile, speaker):
filelist = "filelist.txt"
basefile = sourcefile.replace(".txt", "")
outputm4a = f"{basefile} ({speaker}).m4a"
outputm4b = f"{basefile} ({speaker}).m4b"
with open(filelist, "w") as f:
for filename in files:
filename = filename.replace("'", "'\\''")
f.write(f"file '{filename}'\n")
ffmpeg_command = [
"ffmpeg",
"-f",
"concat",
"-safe",
"0",
"-i",
filelist,
"-codec:a",
"flac",
"-f",
"mp4",
"-strict",
"-2",
outputm4a,
]
subprocess.run(ffmpeg_command)
ffmpeg_command = [
"ffmpeg",
"-i",
outputm4a,
"-i",
"FFMETADATAFILE",
"-map_metadata",
"1",
"-codec",
"aac",
outputm4b,
]
subprocess.run(ffmpeg_command)
os.remove(filelist)
os.remove("FFMETADATAFILE")
os.remove(outputm4a)
for f in files:
os.remove(f)
return outputm4b
def add_cover(cover_img, filename):
try:
if os.path.isfile(cover_img):
m4b = mp4.MP4(filename)
cover_image = open(cover_img, "rb").read()
m4b["covr"] = [mp4.MP4Cover(cover_image)]
m4b.save()
else:
print(f"Cover image {cover_img} not found")
except:
print(f"Cover image {cover_img} not found")
+363
View File
@@ -0,0 +1,363 @@
#!/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)
"""Preprocess epub books into txt files.
"""
# stdlib modules
import os
import sys
import re
import zipfile
# pip installed packages
import numpy as np
import warnings
from tqdm import tqdm
from bs4 import BeautifulSoup
import ebooklib
from ebooklib import epub
import soundfile as sf
from lxml import etree
from PIL import Image
import nltk
from nltk.tokenize import sent_tokenize
# Local imports
from .tts_generic import GenericTTSBackend
from . import logger, PathLike
namespaces = {
"calibre":"http://calibre.kovidgoyal.net/2009/metadata",
"dc":"http://purl.org/dc/elements/1.1/",
"dcterms":"http://purl.org/dc/terms/",
"opf":"http://www.idpf.org/2007/opf",
"u":"urn:oasis:names:tc:opendocument:xmlns:container",
"xsi":"http://www.w3.org/2001/XMLSchema-instance",
}
warnings.filterwarnings("ignore", module="ebooklib.epub")
def ensure_punkt():
try:
nltk.data.find("tokenizers/punkt")
except LookupError:
nltk.download("punkt")
try:
nltk.data.find("tokenizers/punkt_tab")
except LookupError:
nltk.download("punkt_tab")
def chap2text_epub(chap, item_id=None, toc=None):
"""
Extract chapter title and paragraphs from an EPUB chapter.
Args:
chap: The chapter content (HTML).
item_id: The ID of the item in the EPUB spine (for fallback naming).
toc: The EPUB's table of contents (for fallback title extraction).
Returns:
tuple: (chapter_title_text, paragraphs)
"""
blacklist = [
"[document]",
"noscript",
"header",
"html",
"meta",
"head",
"input",
"script",
]
paragraphs = []
soup = BeautifulSoup(chap, "html.parser")
# Step 1: Try to find chapter title in heading tags (<h1>, <h2>, <h3>)
heading_tags = ['h1', 'h2', 'h3']
chapter_title_text = None
for tag in heading_tags:
heading = soup.find(tag)
if heading and heading.text.strip():
chapter_title_text = heading.text.strip()
print(f"Found title in <{tag}>: '{chapter_title_text}'")
break
# Step 2: If no heading found, try elements with common class names
if not chapter_title_text:
common_classes = ['chapter', 'chapter-title', 'title', 'heading']
for class_name in common_classes:
element = soup.find(class_=class_name)
if element and element.text.strip():
chapter_title_text = element.text.strip()
print(f"Found title in class '{class_name}': '{chapter_title_text}'")
break
# Step 3: Fallback to TOC if provided
if not chapter_title_text and toc and item_id:
for toc_item in toc:
if toc_item.href.split('#')[0] == item_id:
chapter_title_text = toc_item.title
print(f"Found title in TOC for item '{item_id}': '{chapter_title_text}'")
break
# Step 4: Fallback to item ID or generic name
if not chapter_title_text:
chapter_title_text = item_id.replace('.xhtml', '').replace('_', ' ').title() if item_id else None
print(f"No title found, using fallback: '{chapter_title_text}'")
# Remove footnotes (links with only numbers)
for a in soup.findAll("a", href=True):
if not any(char.isalpha() for char in a.text):
a.extract()
# Remove superscript numbers (e.g., footnote markers)
for sup in soup.findAll("sup"):
if sup.text.isdigit():
sup.extract()
# Extract paragraphs
chapter_paragraphs = soup.find_all("p")
if not chapter_paragraphs:
print(f"No <p> tags found in '{chapter_title_text or item_id}'. Trying <div>.")
chapter_paragraphs = soup.find_all("div")
for p in chapter_paragraphs:
paragraph_text = "".join(p.strings).strip()
if paragraph_text:
paragraphs.append(paragraph_text)
return chapter_title_text, paragraphs
def get_epub_cover(epub_path):
try:
with zipfile.ZipFile(epub_path) as z:
t = etree.fromstring(z.read("META-INF/container.xml"))
rootfile_path = t.xpath("/u:container/u:rootfiles/u:rootfile",
namespaces=namespaces)[0].get("full-path")
t = etree.fromstring(z.read(rootfile_path))
cover_meta = t.xpath("//opf:metadata/opf:meta[@name='cover']",
namespaces=namespaces)
if not cover_meta:
print("No cover image found.")
return None
cover_id = cover_meta[0].get("content")
cover_item = t.xpath("//opf:manifest/opf:item[@id='" + cover_id + "']",
namespaces=namespaces)
if not cover_item:
print("No cover image found.")
return None
cover_href = cover_item[0].get("href")
cover_path = os.path.join(os.path.dirname(rootfile_path), cover_href)
if os.name == 'nt' and '\\' in cover_path:
cover_path = cover_path.replace("\\", "/")
return z.open(cover_path)
except FileNotFoundError:
print(f"Could not get cover image of {epub_path}")
def export(book, sourcefile):
book_contents = []
cover_image = get_epub_cover(sourcefile)
image_path = None
if cover_image is not None:
image = Image.open(cover_image)
image_filename = sourcefile.replace(".epub", ".png")
image_path = os.path.join(image_filename)
image.save(image_path)
print(f"Cover image saved to {image_path}")
# Get the table of contents
toc = book.get_toc() if hasattr(book, 'get_toc') else []
spine_ids = [spine_tuple[0] for spine_tuple in book.spine if spine_tuple[1] == 'yes']
items = {item.get_id(): item for item in book.get_items() if item.get_type() == ebooklib.ITEM_DOCUMENT}
for id in spine_ids:
item = items.get(id)
if item is None:
continue
# Pass item_id and toc to chap2text_epub
chapter_title, chapter_paragraphs = chap2text_epub(item.get_content(), item_id=id, toc=toc)
book_contents.append({"title": chapter_title, "paragraphs": chapter_paragraphs})
outfile = sourcefile.replace(".epub", ".txt")
check_for_file(outfile)
print(f"Exporting {sourcefile} to {outfile}")
author = book.get_metadata("DC", "creator")[0][0]
booktitle = book.get_metadata("DC", "title")[0][0]
with open(outfile, "w", encoding='utf-8') as file:
file.write(f"Title: {booktitle}\n")
file.write(f"Author: {author}\n\n")
file.write(f"# Title\n")
file.write(f"{booktitle}, by {author}\n\n")
for i, chapter in enumerate(book_contents, start=1):
if not chapter["paragraphs"] or chapter["paragraphs"] == ['']:
continue
else:
# Use chapter title if available, otherwise fallback to "Part {i}"
title = chapter["title"] if chapter["title"] else f"Part {i}"
file.write(f"# {title}\n\n")
for paragraph in chapter["paragraphs"]:
clean = re.sub(r'[\s\n]+', ' ', paragraph)
clean = re.sub(r'[“”]', '"', clean) # Curly double quotes to standard double quotes
clean = re.sub(r'[‘’]', "'", clean) # Curly single quotes to standard single quotes
clean = re.sub(r'--', ', ', clean)
file.write(f"{clean}\n\n")
return book_contents
def get_book(sourcefile):
book_contents = []
book_title = sourcefile
book_author = "Unknown"
chapter_titles = []
with open(sourcefile, "r", encoding="utf-8") as file:
current_chapter = {"title": "blank", "paragraphs": []}
initialized_first_chapter = False
lines_skipped = 0
for line in file:
if lines_skipped < 2 and (line.startswith("Title") or line.startswith("Author")):
lines_skipped += 1
if line.startswith('Title: '):
book_title = line.replace('Title: ', '').strip()
elif line.startswith('Author: '):
book_author = line.replace('Author: ', '').strip()
continue
line = line.strip()
if line.startswith("#"):
if current_chapter["paragraphs"] or not initialized_first_chapter:
if initialized_first_chapter:
book_contents.append(current_chapter)
current_chapter = {"title": None, "paragraphs": []}
initialized_first_chapter = True
chapter_title = line[1:].strip()
if any(c.isalnum() for c in chapter_title):
current_chapter["title"] = chapter_title
chapter_titles.append(current_chapter["title"])
else:
current_chapter["title"] = "blank"
chapter_titles.append("blank")
elif line:
if not initialized_first_chapter:
chapter_titles.append("blank")
initialized_first_chapter = True
if any(char.isalnum() for char in line):
sentences = sent_tokenize(line)
cleaned_sentences = [s for s in sentences if any(char.isalnum() for char in s)]
line = ' '.join(cleaned_sentences)
current_chapter["paragraphs"].append(line)
# Append the last chapter if it contains any paragraphs.
if current_chapter["paragraphs"]:
book_contents.append(current_chapter)
return book_contents, book_title, book_author, chapter_titles
def sort_key(s):
# extract number from the string
return int(re.findall(r'\d+', s)[0])
def check_for_file(filename):
if os.path.isfile(filename):
print(f"The file '{filename}' already exists.")
overwrite = input("Do you want to overwrite the file? (y/n): ")
if overwrite.lower() != 'y':
print("Exiting without overwriting the file.")
sys.exit()
else:
os.remove(filename)
def append_silence(tempfile, duration=1200):
audio = AudioSegment.from_file(tempfile)
# Create a silence segment
silence = AudioSegment.silent(duration)
# Append the silence segment to the audio
combined = audio + silence
# Save the combined audio back to file
combined.export(tempfile, format="flac")
def break_long_sentence(sentence, max_length=200):
# Split sentence based on commas
comma_segments = sentence.split(',')
segments = []
current_segment = ""
for segment in comma_segments:
# Check if adding the next segment exceeds max_length
temp_segment = current_segment + ("," if current_segment else "") + segment
if len(temp_segment) > max_length:
# Add the current segment to the list and reset it
if current_segment:
segments.append(current_segment)
# Start a new segment with the current part
current_segment = segment.strip()
else:
# Continue building the current segment
current_segment = temp_segment.strip()
# Don't forget to add the last segment if it exists
if current_segment:
segments.append(current_segment)
return segments
def process_large_text(line):
# Tokenize the text into sentences
sentences = sent_tokenize(line)
# Initialize a list to store processed sentences
results = []
i = 0
while i < len(sentences):
sentence = sentences[i]
word_count = len(sentence.split())
# Combine with the next sentence if this one has fewer than 8 words
if word_count < 8 and i + 1 < len(sentences):
# Combine the current and next sentence
sentence = sentence + ' ' + sentences[i + 1]
i += 1 # Skip the next sentence since it's already combined
if len(sentence) > 500:
# Break the long sentences into smaller parts using commas
results.extend(break_long_sentence(sentence, max_length=350))
else:
results.append(sentence)
i += 1 # Move to the next sentence
# Before returning, combine last elements if they are too short
if results and len(results[-1].split()) < 8:
if len(results) > 1:
# Combine the last two sentences if they are both short
results[-2] += ' ' + results[-1]
results.pop()
return results
def conditional_sentence_case(sent):
# Split the sentence into words
words = sent.split()
length = len(words)
# Iterate through words to check for three consecutive uppercase words
for i in range(length - 2):
if words[i].isupper() and words[i+1].isupper() and words[i+2].isupper():
# Convert the entire sentence to lowercase and capitalize the first letter
sent = ' '.join(words).lower().capitalize()
break # No need to continue checking once a match is found
return sent
def preprocess_book(book_path):
ensure_punkt()
book = epub.read_epub(book_path)
export(book, book_path)
+1 -1
View File
@@ -13,7 +13,7 @@ import zipfile
from pathlib import Path
from typing import Optional
from epub_tts.vendor_utils import get_repo_path
from epub_tts.utils import get_repo_path
def get_project_root() -> Path:
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
# -*- coding: utf-8; tab-width: 4; -*-
# vim: set fileencoding=utf-8 tabstop=4 shiftwidth=4 expandtab:
# 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)
from pathlib import Path
import os
import sys
import logging
from datetime import datetime as dt
from dotenv import load_dotenv
from .logger import logger, create_logger
logger = create_logger(__name__)
from spantrack import cli, run_config
load_dotenv()
os.environ["NLTK_DATA"] = 'C:\\Users\\renat\\AppData\\Roaming\\nltk_data'
os.environ["HF_TOKEN"] = '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'
# Globals
time_format: str = os.environ.get("TIMEFMT", r"%Y-%m-%dT%H:%M:%S.%f")
caught_exceptions: tuple[Exception] = (KeyboardInterrupt, BaseException)
def main():
logger.info("Start main function.")
root = Path('D:\\DATA\\EBOOKS\\_SEM_DRM\\')
globs = [
#"Night School*.epub",
#"Midnight Line*.epub",
#"Past Tense*.epub",
#"Blue Moon*.epub",
"The Sentinel*.epub",
"Better Off Dead*.epub",
"No Plan B*.epub",
]
cmd = []
if "--dry-run" in sys.argv:
cmd.extend("--dry-run")
cmd.extend(["--progress",
"--device", run_config.Device["CUDA"], # spantrack.run_config.Device, default=Device.AUTO,
#"--lexicon", # Path
#"--audio-format", # AudioFormat, default=AudioFormat.MP3, spantrack.run_config._AUDIO_MEDIA_TYPES
#"--lang", # Language, default=Language.EN_US,
"--voice","am_liam",
])
result_output = {"pending":[],"success":[],"error":[]}
logger.info("Start main loop: ")
logger.info("try (for glob in globs: %s", ", ".join([f"'{str(g)}'" for g in globs]))
logger.info("except: %s", ", ".join([f"{e.__name__}" for e in caught_exceptions]) )
logger.info("finally: sys.exit( len(errors) + len(pending) )")
try:
for glob_i,glob in enumerate(globs):
file_list = [f for f in root.glob(glob)]
if len(file_list) != 1:
logger.error(f"Glob pattern '{glob}' found {"more" if len(file_list)>1 else "less"} than 1 file: %s", file_list)
result_output["error"].append(glob)
continue
result_output["pending"].append(str(file_list[0]))
logger.info(
"Files to parse:\n%s",
",\n".join([f"[{i+1}] '{str(f)}'" for i,f in enumerate(result_output["pending"])]))
for file in result_output["pending"]:
start_dt = dt.now()
logger.info(f"[{start_dt.strftime(time_format)}] Narrating '{file}'")
cli.main([file] + cmd)
finish_dt = dt.now()
logger.info(f"[{finish_dt .strftime(time_format)}] Success! '{file}'")
elapsed_time = finish_dt-start_dt
elapsed_total_seconds = elapsed_time.total_seconds()
elapsed_hours= elapsed_total_seconds // 3600
elapsed_minutes = (elapsed_total_seconds % 3600) // 60
elapsed_seconds = elapsed_total_seconds % 60
elapsed_msg = "Total elapsed time: %dh %dmin %ds"
logger.info(elapsed_msg, elapsed_hours, elapsed_minutes, elapsed_seconds)
result_output["success"].append(file)
result_output["pending"].remove(file)
except caught_exceptions as e:
logger.critical("Caught exception: %s", str(e))
finally:
logger.info("Exiting with results:\nDONE:%s\nLIST:%s\nERR:%s",
",\n".join([f"\t[{i+1}] '{str(f)}'" for i,f in enumerate(result_output["success"])]),
",\n".join([f"\t[{i+1}] '{str(f)}'" for i,f in enumerate(result_output["pending"])]),
",\n".join([f"\t[{i+1}] '{str(f)}'" for i,f in enumerate(result_output["error"])]),
)
sys.exit(
len(result_output["pending"]) +
len(result_output["error"])
)
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
# 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 .tts_generic import GenericTTSBackend
from . import logger, PathLike
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.
"""
FLAGS = {
"speaker": "--speaker",
"voice": "--speaker",
"language": "--language",
"cover": "--cover",
}
INTERMEDIATE_TXT = True
INTERMEDIATE_CALL = GenericTTSBackend._replace_map
def gen_input_flag(self, input_path) -> list[str]:
"""Return backend flag for input file or directory."""
p = self._normalize_path(input_path)
return [str(p)] # epub2tts-edge expects a single path, no flag
gen_output_flag = None
class AedocwEpub2TTS(AedocwBackend):
"""Backend configured for github.com/aedocw/epub2tts."""
pass
class AedocwEpub2TTSEdge(AedocwBackend):
"""Backend configured for github.com/aedocw/epub2tts-edge."""
DEFAULT_SPEAKER = "en-US-AndrewNeural"
CMD = ["-c", "from epub2tts_edge import main;main()"]
REPO = "epub2tts-edge"
class TTSEdge(AedocwEpub2TTSEdge):
"""Backward-compatible edge backend wrapper."""
pass
class AedocwChatterbox(AedocwBackend):
"""Backend configured for github.com/aedocw/epub2tts-chatterbox."""
DEFAULT_SAMPLE = "none"
class AedocwKokoro(AedocwBackend):
"""Backend configured for github.com/aedocw/epub2tts-kokoro."""
#DEFAULT_SPEAKER = "af_heart"
DEFAULT_SPEAKER = "am_liam"
CMD = ["-c", "from epub2tts_kokoro import main;main()"]
REPO = "epub2tts-kokoro"
FLAGS = {
"--speaker": "voice", # str, default="af_heart"
"--cover": "cover", # str, default=None
"--paragraphpause": "long_pause", # int, default=600 (ms)
"--speed": "speed", # float, default=1.3
}
DEFAULT_FLAGS = {
"voice": "am_liam",
"cover": None,
"long_pause": 600,
"speed": 1.3,
}
def get_speakers(self) -> list[str]:
"""Return list of available speakers."""
return ["af_heart", "af_joy", "af_sad", "af_angry", "af_fear", "af_surprise"]
def gen_speaker_samples(
self,
samples: list =None,
output_path: PathLike=None,
) -> list[str]:
"""Generate sample speakers audio in output_path."""
result = self._build_command("gen_samples.py", *samples, output_path=output_path)
return [str(self._normalize_path(s)) for s in samples]
class Epub2TTS(AedocwBackend):
"""Backward-compatible chatterbox backend wrapper."""
pass
__all__ = [
"AedocwBackend",
"AedocwEpub2TTS",
"AedocwEpub2TTSEdge",
"AedocwChatterbox",
"AedocwKokoro",
"TTSEdge",
"Epub2TTS",
]
-78
View File
@@ -1,78 +0,0 @@
#!/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)
"""Generic TTS Backend for handling text-to-speech conversion.
This module provides a generic TTS backend that can be used to convert text to speech using various TTS engines. It defines a `GenericTTSBackend` class that can be initialized with a specific backend command, language, and voice. The class provides methods to normalize paths, determine input and output flags based on the type of input/output (file or directory), build the command for the TTS engine, and run the command.
Classes:
GenericTTSBackend:
Parameters:
backend_cmd (str): Command or executable for the backend.
language (Optional[str]): Language code for TTS output.
voice (Optional[str]): Voice name for TTS output.
Methods:
_normalize_path(value: PathLike) -> Path: Normalize a string or Path to Path.
_input_flag(input_path: Path) -> str: Return backend flag for input file or directory.
_output_flag(output_path: Path) -> str: Return backend flag for output file or directory.
_build_command(input_source: PathLike, output_dest: PathLike) -> list[str]: Build command list for subprocess.
run(input_source: PathLike, output_dest: PathLike) -> subprocess.CompletedProcess: Execute backend command.
"""
import subprocess
from pathlib import Path
from typing import Optional, Union
PathLike = Union[str, Path]
class GenericTTSBackend:
def __init__(
self,
backend_cmd: str,
language: Optional[str] = None,
voice: Optional[str] = None,
):
self.backend_cmd = backend_cmd
self.language = language
self.voice = voice
def _normalize_path(self, value: PathLike) -> Path:
if isinstance(value, Path):
return value
return Path(value)
def _input_flag(self, input_path: Path) -> str:
return "--input-dir" if input_path.is_dir() else "--input-file"
def _output_flag(self, output_path: Path) -> str:
return "--output-dir" if output_path.is_dir() else "--output-file"
def _build_command(
self, input_source: PathLike, output_dest: PathLike
) -> list[str]:
input_path = self._normalize_path(input_source)
output_path = self._normalize_path(output_dest)
command = [
self.backend_cmd,
self._input_flag(input_path),
str(input_path),
self._output_flag(output_path),
str(output_path),
]
if self.language:
command.extend(["--language", self.language])
if self.voice:
command.extend(["--voice", self.voice])
return command
def run(
self, input_source: PathLike, output_dest: PathLike
) -> subprocess.CompletedProcess:
command = self._build_command(input_source, output_dest)
return subprocess.run(command, check=True)
-13
View File
@@ -1,13 +0,0 @@
#!/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
+168
View File
@@ -0,0 +1,168 @@
#!/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)
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
"""Generic TTS Backend for handling text-to-speech conversion.
This module provides a generic TTS backend that can be used to convert text to speech using various TTS engines. It defines a `GenericTTSBackend` class that can be initialized with a specific backend command, language, and voice. The class provides methods to normalize paths, determine input and output flags based on the type of input/output (file or directory), build the command for the TTS engine, and run the command.
Classes:
GenericTTSBackend:
Parameters:
backend_cmd (str): Command or executable for the backend.
language (Optional[str]): Language code for TTS output.
voice (Optional[str]): Voice name for TTS output.
Methods:
_normalize_path(value: PathLike) -> Path: Normalize a string or Path to Path.
gen_input_flag(input_path: Path) -> str: Return backend flag for input file or directory.
gen_output_flag(output_path: Path) -> str: Return backend flag for output file or directory.
_build_command(input_source: PathLike, output_dest: PathLike) -> list[str]: Build command list for subprocess.
run(input_source: PathLike, output_dest: PathLike) -> subprocess.CompletedProcess: Execute backend command.
"""
import os
import re
import subprocess
from pathlib import Path
from typing import Optional, Callable
from .utils import build_run_command, ensure_venv
from . import logger, PathLike
class GenericTTSBackend:
"""Generic TTS backend for handling text-to-speech conversion.
Parameters:
backend_cmd (str): Command or executable for the backend.
language (Optional[str]): Language code for TTS output.
voice (Optional[str]): Voice name for TTS output.
"""
REPO: Optional[str] = None
CMD: Optional[str] = None
FLAGS: Optional[dict] = {}
DEFAULT_FLAGS: Optional[dict] = {}
INTERMEDIATE_TXT: Optional[bool] = False
INTERMEDIATE_CALL: Optional[Callable] = None
DEFAULT_SPEAKER: Optional[str] = None
DEFAULT_SAMPLE: Optional[str] = None
SUPPORTED_AUDIO_FORMATS = {
# subclasses may override this
".m4b": "m4b",
".wav": "wav",
".flac": "flac",
".mp3": "mp3",
".ogg": "ogg",
}
INPUT_FLAG = {True: "--input-dir", False: "--input-file"}
OUTPUT_FLAG = {True: "--output-dir", False: "--output-file"}
ENV = None # subclasses may override this to set environment variables for the backend
def __init__(self, **kwargs):
logger.info(kwargs)
for k,v in kwargs.items():
setattr(self, k, v)
if hasattr(self, 'CWD') and self.CWD is not None:
self.CWD = Path(self.CWD).resolve()
else:
self.CWD = self._normalize_path(self.output).resolve() if hasattr(self, 'output') else Path.cwd()
if not self.CWD.is_dir():
self.CWD = self.CWD.parent.resolve()
self.ENV = os.environ.copy()
#logger.info(f"SELF:{self.__dict__}")
def _normalize_path(self, value: PathLike) -> Path:
if isinstance(value, Path):
return value.resolve()
else:
return Path(value).resolve()
def gen_input_flag(self, input_path: Path) -> list[str]:
p = self._normalize_path(input_path)
return [self.INPUT_FLAG[p.is_dir()], str(p)]
def gen_output_flag(self, output_path: Path) -> list[str]:
p = self._normalize_path(output_path)
return [self.OUTPUT_FLAG[p.is_dir()], str(p)]
def _build_command(self, input_source: PathLike, output_dest: PathLike) -> list[str]:
if not self.CMD and not self.REPO:
raise ValueError("Either CMD or REPO must be specified for the backend.")
if self.CMD and not self.REPO:
command = [self.CMD]
elif self.REPO and not self.CMD:
command = [str(ensure_venv(self.REPO))]
else:
command = [str(ensure_venv(self.REPO))]
command.extend(["-m",self.CMD] if isinstance(self.CMD, str)
else self.CMD)
command.extend(self.gen_input_flag(input_source))
if self.gen_output_flag:
command.extend(self.gen_output_flag(output_dest))
for flag_name, var_name in self.FLAGS.items():
if getattr(self, var_name, self.DEFAULT_FLAGS[var_name]) is not None:
command.extend([flag_name, str(getattr(self, var_name))
])
return command
def run(self,
input_source: PathLike,
output_dest: PathLike,
**kwargs
) -> subprocess.CompletedProcess:
command = self._build_command(input_source, output_dest)
logger.info(command)
# PREPROCESSING STEP: If INTERMEDIATE_TXT is True, run the command to generate intermediate text first
if not input_source.endswith(".txt"):
completed = subprocess.run(
self._build_command(input_source, output_dest),
cwd=str(self.CWD),
env=self.ENV,
check=True)
if self.INTERMEDIATE_TXT:
txt_file = self._normalize_path(input_source).with_suffix(".txt")
if txt_file.exists():
if (self.INTERMEDIATE_CALL is not None and
kwargs.get('replace_map', None) is not None):
self.INTERMEDIATE_CALL(
txt_file,
txt_file.with_stem(txt_file.stem + "_replaced"), kwargs.get('replace_map', {}))
txt_file = txt_file.with_stem(txt_file.stem + "_replaced")
completed = subprocess.run(
self._build_command(txt_file, output_dest),
cwd=str(self.CWD),
env=self.ENV,
check=True
)
return completed
def _replace_map(self,
input_path: PathLike,
output_path: PathLike,
replace_map: dict = None,
):
"""Run a simple regex substitution for the intermediate text extraction step."""
if replace_map is not None:
pattern = re.compile("|".join(re.escape(key) for key in replace_map.keys()))
with open(input_path, 'r') as src, open(output_path, 'w') as dest:
for line in src:
dest.write(
pattern.sub(lambda match: replace_map[match.group(0)], line))
+313
View File
@@ -0,0 +1,313 @@
#!/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)
"""Backend for the kokoro engine.
No vendored repository needed, kokoro is a pure Python package that can be installed via pip.
"""
# stdlib modules
import subprocess
from pathlib import Path
import os
import sys
# Automatically enable MPS fallback on Apple Silicon macOS
if sys.platform == 'darwin':
os.environ['PYTORCH_ENABLE_MPS_FALLBACK'] = '1'
# pip installed packages
import numpy as np
import soundfile
import torch
from tqdm import tqdm
from kokoro import KPipeline
from ebooklib import epub
import soundfile as sf
from mutagen import mp4
from pydub import AudioSegment
from mutagen import mp4
# Local imports
from .tts_generic import GenericTTSBackend
from . import logger, PathLike
from .preprocess import preprocess_book
class KokoroBackend(GenericTTSBackend):
"""Generic kokoro 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.
"""
INTERMEDIATE_TXT = True
INTERMEDIATE_CALL = GenericTTSBackend._replace_map
DEFAULT_SPEAKER = "am_liam" # ["af_heart", "am_michael", "am_liam"]
def run(self,
input_source: PathLike,
output_dest: PathLike,
**kwargs
) -> subprocess.CompletedProcess:
command = self._build_command(input_source, output_dest)
logger.info(command)
# # PREPROCESSING STEP: If INTERMEDIATE_TXT is True, run the command to generate intermediate text first
# if not input_source.endswith(".txt"):
# completed = subprocess.run(
# self._build_command(input_source, output_dest),
# cwd=str(self.CWD),
# env=self.ENV,
# check=True)
# if self.INTERMEDIATE_TXT:
# txt_file = self._normalize_path(input_source).with_suffix(".txt")
# if txt_file.exists():
# if (self.INTERMEDIATE_CALL is not None and
# kwargs.get('replace_map', None) is not None):
# self.INTERMEDIATE_CALL(
# txt_file,
# txt_file.with_stem(txt_file.stem + "_replaced"), kwargs.get('replace_map', {}))
# txt_file = txt_file.with_stem(txt_file.stem + "_replaced")
# completed = subprocess.run(
# self._build_command(txt_file, output_dest),
# cwd=str(self.CWD),
# env=self.ENV,
# check=True
# )
# If we get an epub, export that to txt file
if input_source.endswith(".epub"):
book = preprocess_book(input_source)
# Check for GPU
if torch.cuda.is_available():
print('Nvidia GPU available. Setting as default device.')
torch.set_default_device('cuda')
elif torch.xpu.is_available():
print('Intel XPU (GPU) available. Setting as default device.')
torch.set_default_device('xpu')
elif torch.backends.mps.is_available():
print('Apple MPS GPU available. Setting as default device.')
torch.set_default_device('mps')
elif torch.backends.rocm.is_available():
print('AMD ROCm GPU available. Setting as default device.')
torch.set_default_device('rocm')
elif torch.is_vulkan_available():
print('Vulkan GPU available. Setting as default device.')
torch.set_default_device('vulkan')
else:
print('No GPU available. Using CPU.')
torch.set_default_device('cpu')
book_contents, book_title, book_author, chapter_titles = get_book(args.sourcefile)
files = read_book(book_contents, args.speaker, args.paragraphpause, args.speed, args.notitles)
generate_metadata(files, book_author, book_title, chapter_titles)
m4bfilename = make_m4b(files, args.sourcefile, args.speaker)
add_cover(args.cover, m4bfilename)
def kokoro_read(paragraph, speaker, filename, pipeline, speed):
audio_segments = []
sentences = process_large_text(paragraph)
for sent in sentences:
sent = conditional_sentence_case(sent.strip())
for gs, ps, audio in pipeline(sent, voice=speaker, speed=speed, split_pattern=r'\n\n\n'):
audio_segments.append(audio)
final_audio = np.concatenate(audio_segments)
soundfile.write(filename, final_audio, 24000)
def read_book(book_contents, speaker, paragraphpause, speed, notitles):
current_device_name = torch.get_default_device() if torch.get_default_device() else 'cpu'
current_device = torch.device(current_device_name)
print(f"Attempting to use device: {current_device}")
pipeline = KPipeline(lang_code=speaker[0])
# Explicitly move the model to the current default device (e.g., 'xpu')
if hasattr(pipeline, 'model') and pipeline.model is not None:
try:
pipeline.model.to(current_device)
print(f"Kokoro model explicitly moved to {current_device}")
except Exception as e:
print(f"Error moving Kokoro model to {current_device}: {e}")
else:
print("Warning: KPipeline does not have a 'model' attribute or model is None.")
segments = []
for i, chapter in enumerate(book_contents, start=1):
files = []
partname = f"part{i}.flac"
print(f"\n\n")
if os.path.isfile(partname):
print(f"{partname} exists, skipping to next chapter")
segments.append(partname)
else:
print(f"Chapter: {chapter['title']}\n")
print(f"Section name: \"{chapter['title']}\"")
if chapter["title"] == "":
chapter["title"] = "blank"
if chapter["title"] != "Title" and notitles != True:
title_temp = "title.flac"
if not os.path.isfile(title_temp):
kokoro_read(chapter['title'] + ".", speaker, "title_temp.wav", pipeline, speed)
append_silence("title_temp.wav", paragraphpause)
# Convert to flac
audio = AudioSegment.from_file("title_temp.wav")
audio.export(title_temp, format="flac")
os.remove("title_temp.wav")
files.append(title_temp)
for pindex, paragraph in enumerate(
tqdm(chapter["paragraphs"], desc=f"Generating audio files: ",unit='pg')
):
ptemp = f"pgraphs{pindex}.flac"
if os.path.isfile(ptemp):
print(f"{ptemp} exists, skipping to next paragraph")
else:
#sentences = sent_tokenize(paragraph)
filenames = ["sntnc1.wav"]
kokoro_read(paragraph, speaker, "sntnc1.wav", pipeline, speed)
append_silence("sntnc1.wav", paragraphpause)
# combine sentences in paragraph
sorted_files = sorted(filenames, key=sort_key)
if os.path.exists("sntnc0.wav"):
sorted_files.insert(0, "sntnc0.wav")
combined = AudioSegment.empty()
for file in sorted_files:
combined += AudioSegment.from_file(file)
combined.export(ptemp, format="flac")
for file in sorted_files:
os.remove(file)
files.append(ptemp)
# combine paragraphs into chapter
append_silence(files[-1], 2000)
combined = AudioSegment.empty()
for file in files:
combined += AudioSegment.from_file(file)
combined.export(partname, format="flac")
for file in files:
os.remove(file)
segments.append(partname)
return segments
# ***
# READ
def read_from_txt_to_wav(text_file:PathLike,
speaker:str="af_heart") -> Path:
if not isinstance(text_file,Path):
text_file = Path(text_file).resolve()
else:
text_file = text_file.resolve()
# Check if text file exists
if not text_file.exists():
print(f"Error: Text file '{text_file}' not found.")
return False
# Read the text from the file
with open(text_file, 'r', encoding='utf-8') as f:
text_contents = f.read()
# Generate output filename (replace .txt extension with .wav)
output_file = text_file.with_suffix('.wav')
# Check for CUDA GPU
if torch.cuda.is_available():
print('CUDA GPU available')
torch.set_default_device('cuda')
print(f"Generating audio for speaker '{speaker}' from '{text_file}'...")
# Create pipeline with language code (first character of speaker name)
pipeline = KPipeline(lang_code=speaker[0])
# Generate audio segments
audio_segments = []
for gs, ps, audio in pipeline(text_contents, voice=speaker, speed=1, split_pattern=r'\n\n\n'):
audio_segments.append(audio)
# Concatenate all audio segments
final_audio = np.concatenate(audio_segments)
# Write to wav file
soundfile.write(output_file, final_audio, 24000)
print(f"Audio saved to '{output_file}'")
return output_file
def get_speakers(self) -> list[str]:
"""Return list of available speakers.
See https://huggingface.co/hexgrad/Kokoro-82M/blob/main/VOICES.md"""
speakers = [
# 🇺🇸 American English: 11F 9M
# Overall Grade 'A'
"af_heart",
# Overall Grade 'C+'
"af_aoede","af_kore","af_sarah",
"am_fenrir","am_michael","am_puck",
# Other
"af_alloy", "af_bella", "af_jessica", "af_nicole", "af_nova", "af_river", "af_sky", "am_adam", "am_echo", "am_eric", "am_liam", "am_onyx", "am_santa", "bf_alice",
# 🇬🇧 British English: 4F 4M
"bf_emma", "bf_isabella", "bf_lily", "bm_daniel", "bm_fable", "bm_george", "bm_lewis",
# 🇧🇷 Brazilian Portuguese: 1F 2M
"pf_dora",
"pm_alex",
"pm_santa"]
# Old list:
# ["af_heart", "af_joy", "af_sad", "af_angry", "af_fear", "af_surprise"]
return speakers
def gen_speaker_samples(
self,
samples: list =None,
output_path: PathLike=None,
) -> list[str]:
"""Generate sample speakers audio in output_path."""
result = self._build_command("gen_samples.py", *samples, output_path=output_path)
return [str(self._normalize_path(s)) for s in samples]
def gen_speaker_samples():
if torch.cuda.is_available():
print('CUDA GPU available')
torch.set_default_device('cuda')
for speaker in speakers:
file = speaker + "_sample.wav"
if os.path.exists(file):
print(f"Sample for {speaker} already exists.")
continue
else:
print(f"Creating {speaker}")
pipeline = KPipeline(lang_code=speaker[0])
sentence = f"Hello, this voice is {speaker[3:]}. The quick brown fox jumped over the lazy dog. The fish twisted and turned on the bent hook. Press the pants and sew a button on the vest. The swan dive was far short of perfect."
audio_segments = []
for gs, ps, audio in pipeline(
sentence,
repo_id='hexgrad/Kokoro-82M',
voice=speaker,
speed=1,
split_pattern=r'\n\n\n'):
audio_segments.append(audio)
final_audio = np.concatenate(audio_segments)
soundfile.write(file, final_audio, 24000)
+176
View File
@@ -0,0 +1,176 @@
#!/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)
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
"""Utilities for running vendored EPUB->TTS repositories in isolated venvs."""
import subprocess
import shutil
import sys
import venv
from pathlib import Path
from typing import Optional, Union
from collections.abc import MutableMapping
from .logger import logger
from . import PathLike
def path(fpath: str|Path = None):
return Path(fpath).resolve()
class DataDict(dict):
"""Wrapper around dictionary objects for easier dict subclassing
See module [collections](https://github.com/python/cpython/blob/3.14/Lib/collections/__init__.py#L1133)
"""
def __init__(self, *args, **kwargs):
self._data = {}
if args is not None:
if isinstance(args, list):
for a in args:
if not isinstance(a,tuple) or len(a) != 2:
# list is not a list of (k,v) tuples
self._args = args
break
# all list items are (k,v) tuples
self.update(args)
self._args = args
if kwargs:
self.update(kwargs)
# Emulate accessing class attributes
def __getattr__(self, key):
# This gets called only if __getattribute__ doesn't find key
return self._data[key]
def __setattr__(self, key, value):
if key.startswith("_"):
return super().__setattr__ (key, value)
return self._data.__setitem__(key, value)
def __delattr__(self, key):
if key.startswith("_"):
return super().__delattr__(key)
return self._data.__delitem__(key)
# Emulate dict methods
def __len__(self):
return len(self._data)
def __getitem__(self, key):
if key in self._data:
return self._data[key]
if hasattr(self.__class__, "__missing__"):
return self.__class__.__missing__(self, key)
raise KeyError(key)
def __setitem__(self, key, value):
self._data[key] = value
def __delitem__(self, key):
del self._data[key]
def __iter__(self):
return iter(self._data)
def __contains__(self, key):
return key in self._data
def get(self, key, default=None):
if key in self:
return self[key]
return default
def __repr__(self):
return repr(self._data)
def __or__(self, other):
return self._data | other
def __ror__(self, other):
return other | self._data
def __ior__(self, other):
self._data |= other
return self
def __copy__(self):
# TODO: verify if this is correct
return dict(self._data)
def copy(self):
import copy
return copy.copy(self._data)
@classmethod
def fromkeys(cls, iterable, value=None):
d = cls()
for key in iterable:
d[key] = value
return d
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,
# backend_cmd: str = None,
) -> str:
return str(ensure_venv(repo_name))
# if not backend_cmd:
# return str(ensure_venv(repo_name))
# ensure_venv(repo_name)
# venv_path = get_venv_path(repo_name)
# return (
# str( venv_path / "Scripts" / f"{backend_cmd}.exe")
# if sys.platform == "win32"
# else str(venv_path / "bin" / backend_cmd)
# )
-66
View File
@@ -1,66 +0,0 @@
#!/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]