Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b26381a84 | ||
|
|
2d34e175bd | ||
|
|
b45321a19f | ||
|
|
ea16f87fc9 | ||
|
|
2f9b95e095 | ||
|
|
1266aef52a | ||
|
|
da8e4ee602 | ||
|
|
8a0f66ebc6 | ||
|
|
067319f7ef | ||
|
|
2167e9b801 | ||
|
|
67404c71df | ||
|
|
d087b64320 | ||
|
|
2777b34026 | ||
|
|
a1f564a051 | ||
|
|
28ffc1820b | ||
|
|
3553426a72 | ||
|
|
b1c7a42172 | ||
|
|
da8940bdd4 | ||
|
|
f2c66a1876 |
+4
-1
@@ -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
|
||||
._*
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
HF_TOKEN=
|
||||
NLTK_DATA=
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
# )
|
||||
|
||||
@@ -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]
|
||||
Vendored
+1
-1
Submodule src/vendor/epub2tts-edge updated: 6fb7a0f125...a25ead5312
Vendored
+1
-1
Submodule src/vendor/epub2tts-kokoro updated: dd27e5721a...9f25e02fe7
Reference in New Issue
Block a user