Compare commits
20
Commits
f2c66a1876
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b26381a84 | ||
|
|
2d34e175bd | ||
|
|
b45321a19f | ||
|
|
ea16f87fc9 | ||
|
|
2f9b95e095 | ||
|
|
1266aef52a | ||
|
|
da8e4ee602 | ||
|
|
8a0f66ebc6 | ||
|
|
067319f7ef | ||
|
|
2167e9b801 | ||
|
|
67404c71df | ||
|
|
d087b64320 | ||
|
|
2777b34026 | ||
|
|
a1f564a051 | ||
|
|
28ffc1820b | ||
|
|
3553426a72 | ||
|
|
b1c7a42172 | ||
|
|
da8940bdd4 | ||
|
|
b515b8b722 | ||
|
|
fcb4d8884d |
+5
-1
@@ -136,6 +136,9 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
.tmp-samples/
|
||||||
|
.tmp*
|
||||||
|
.venv*
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
@@ -222,7 +225,8 @@ $RECYCLE.BIN/
|
|||||||
.LSOverride
|
.LSOverride
|
||||||
|
|
||||||
# Icon must end with two \r
|
# Icon must end with two \r
|
||||||
Icon␍
|
Icon
|
||||||
|
|
||||||
|
|
||||||
# Thumbnails
|
# Thumbnails
|
||||||
._*
|
._*
|
||||||
|
|||||||
@@ -10,3 +10,9 @@
|
|||||||
[submodule "src/vendor/epub2tts-kokoro"]
|
[submodule "src/vendor/epub2tts-kokoro"]
|
||||||
path = src/vendor/epub2tts-kokoro
|
path = src/vendor/epub2tts-kokoro
|
||||||
url = https://github.com/aedocw/epub2tts-kokoro
|
url = https://github.com/aedocw/epub2tts-kokoro
|
||||||
|
[submodule "src/vendor/epub3-samples"]
|
||||||
|
path = src/vendor/epub3-samples
|
||||||
|
url = https://github.com/IDPF/epub3-samples
|
||||||
|
[submodule "src/vendor/sample-epub-minimal"]
|
||||||
|
path = src/vendor/sample-epub-minimal
|
||||||
|
url = https://github.com/thansen0/sample-epub-minimal
|
||||||
|
|||||||
@@ -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.
|
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
|
# 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
|
# Installation
|
||||||
|
|
||||||
@@ -18,6 +55,18 @@ Recent Python (>=3.11), pip (>=22.3), some sort of virtula environment recommend
|
|||||||
3. Test install running `epub-tts --version`
|
3. Test install running `epub-tts --version`
|
||||||
- Make sure the venv is activated or that pip installed the package in your PATH
|
- Make sure the venv is activated or that pip installed the package in your PATH
|
||||||
|
|
||||||
|
# Sample EPUB download
|
||||||
|
|
||||||
|
After cloning with submodules, the repository now includes the EPUB 3 sample collection under `src/vendor/epub3-samples`.
|
||||||
|
|
||||||
|
Use the new console entrypoints to export the Moby Dick samples as EPUB files:
|
||||||
|
|
||||||
|
- `download-moby-dick` creates `.tmp-samples/moby-dick.epub`
|
||||||
|
- `download-moby-dick-media-overlays` creates `.tmp-samples/moby-dick-mo.epub`
|
||||||
|
- `download-epub-minimal` creates `.tmp-samples/minimal.epub`
|
||||||
|
|
||||||
|
The staging directory `.tmp-samples/` is included in `.gitignore` so generated artifacts stay out of version control.
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||
# Citation
|
# Citation
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
HF_TOKEN=
|
||||||
|
NLTK_DATA=
|
||||||
@@ -27,7 +27,25 @@ classifiers = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"audioop-lts; python_version >= '3.13'",
|
||||||
|
"beautifulsoup4",
|
||||||
|
"colorlog",
|
||||||
|
"dotenv",
|
||||||
|
"ebooklib",
|
||||||
|
"kokoro>=0.9.4",
|
||||||
"load-dotenv>=0.1.0",
|
"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]
|
[project.optional-dependencies]
|
||||||
@@ -41,6 +59,9 @@ Issues = "https://git.silveirarosa.com/renatoxsr/epub-tts/issues"
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
epub-tts = "epub_tts.__main__:main"
|
epub-tts = "epub_tts.__main__:main"
|
||||||
|
download-moby-dick = "epub_tts.sample_download:main"
|
||||||
|
download-moby-dick-media-overlays = "epub_tts.sample_download:main_with_media_overlay"
|
||||||
|
download-epub-minimal = "epub_tts.sample_download:main_minimal_epub"
|
||||||
|
|
||||||
# SETUPTOOLS
|
# SETUPTOOLS
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -12,3 +12,8 @@ try:
|
|||||||
except PackageNotFoundError:
|
except PackageNotFoundError:
|
||||||
# package is not installed
|
# package is not installed
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
#from .logger import logger, loglevel_map, get_logger
|
||||||
|
#from .utils import DataDict, PathLike, path
|
||||||
|
#from .app import App
|
||||||
|
|||||||
+87
-48
@@ -1,37 +1,31 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa
|
# 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)
|
# 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
|
|
||||||
"""
|
|
||||||
import sys
|
import sys
|
||||||
|
<<<<<<< HEAD
|
||||||
|
from pathlib import Path
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
from . import tts_aedocw, tts_edge, tts_generic
|
# 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
|
# keys are the backend names, values are the corresponding TTS classes
|
||||||
"default": tts_generic.GenericTTSBackend,
|
"default": tts_generic.GenericTTSBackend,
|
||||||
"edge": tts_edge.TTSEdge,
|
"edge": tts_aedocw.TTSEdge,
|
||||||
# aedocw-backed implementations — these expect the corresponding
|
# aedocw-backed implementations — these expect the corresponding
|
||||||
# package "console scripts"/entrypoints to be available in the
|
# package "console scripts"/entrypoints to be available in the
|
||||||
# environment (or an explicit backend_cmd to be provided).
|
# environment (or an explicit backend_cmd to be provided).
|
||||||
@@ -39,6 +33,8 @@ backend = {
|
|||||||
"epub2tts-edge": tts_aedocw.AedocwEpub2TTSEdge,
|
"epub2tts-edge": tts_aedocw.AedocwEpub2TTSEdge,
|
||||||
"epub2tts-chatterbox": tts_aedocw.AedocwChatterbox,
|
"epub2tts-chatterbox": tts_aedocw.AedocwChatterbox,
|
||||||
"epub2tts-kokoro": tts_aedocw.AedocwKokoro,
|
"epub2tts-kokoro": tts_aedocw.AedocwKokoro,
|
||||||
|
"generic-epub2tts": tts_aedocw.Epub2TTS,
|
||||||
|
#"kokoro": tts_kokoro.KokoroBackend,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -50,32 +46,75 @@ def main(args=None):
|
|||||||
action="version",
|
action="version",
|
||||||
version=f"%(prog)s {__import__('epub_tts').__version__}",
|
version=f"%(prog)s {__import__('epub_tts').__version__}",
|
||||||
)
|
)
|
||||||
p.add_argument("-i", "--input", help="Input EPUB file", required=True)
|
# Input options and processing
|
||||||
p.add_argument("-o", "--output", help="Output audio file", required=True)
|
p.add_argument("-i", "--input",
|
||||||
p.add_argument("-l", "--language", help="Language to use for TTS")
|
nargs="+", required=True,
|
||||||
p.add_argument("-v", "--voice", help="Voice to use for TTS")
|
help="Input EPUB file")
|
||||||
p.add_argument(
|
p.add_argument("-r", "--replace", action="append", nargs=2,
|
||||||
"-b",
|
help="Replace text in the intermediate output. "
|
||||||
"--backend",
|
"Specify pairs of old_text new_text. "
|
||||||
help="Backend to use for TTS",
|
"Can be used multiple times.")
|
||||||
default="default",
|
p.add_argument("--check-env",action="store_true",
|
||||||
choices=backend.keys(),
|
help="Check the runtime environment and exit")
|
||||||
)
|
p.add_argument("-b", "--backend", default="default",
|
||||||
args = p.parse_args(args or sys.argv[1:])
|
choices=BACKENDS.keys(),
|
||||||
print(
|
help="Backend to use for TTS")
|
||||||
f"Converting {args.input} to {args.output} using voice "
|
|
||||||
f"{args.voice}, language {args.language}, "
|
|
||||||
f"backend {args.backend}, ..."
|
|
||||||
)
|
|
||||||
|
|
||||||
backend_cls = backend[args.backend]
|
# Output options
|
||||||
backend_instance = backend_cls(
|
p.add_argument("-o", "--output",
|
||||||
backend_cmd=None,
|
help="Output audio file", required=True)
|
||||||
language=args.language,
|
p.add_argument("-c", "--cover",
|
||||||
voice=args.voice,
|
help="Path to cover image to embed or use "
|
||||||
)
|
"for output metadata")
|
||||||
backend_instance.run(args.input, args.output)
|
|
||||||
|
# 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__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(cli())
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
#!/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)
|
||||||
|
|
||||||
|
"""Download EPUB sample files from a vendored samples repository and package them as EPUB archives."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from epub_tts.utils import get_repo_path
|
||||||
|
|
||||||
|
|
||||||
|
def get_project_root() -> Path:
|
||||||
|
return Path(__file__).resolve().parents[1].parent
|
||||||
|
|
||||||
|
|
||||||
|
def get_temp_sample_root() -> Path:
|
||||||
|
return get_project_root() / ".tmp-samples"
|
||||||
|
|
||||||
|
|
||||||
|
def get_sample_directory(with_media_overlay: bool) -> Path:
|
||||||
|
sample_name = "moby-dick-mo" if with_media_overlay else "moby-dick"
|
||||||
|
sample_dir = get_repo_path("epub3-samples") / "30" / sample_name
|
||||||
|
if not sample_dir.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Sample directory not found: {sample_dir}. Ensure the epub3-samples submodule is initialized."
|
||||||
|
)
|
||||||
|
return sample_dir
|
||||||
|
|
||||||
|
|
||||||
|
def get_minimal_sample_file() -> Path:
|
||||||
|
sample_file = get_repo_path("sample-epub-minimal") / "minimal.epub"
|
||||||
|
if not sample_file.exists():
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Sample EPUB file not found: {sample_file}. Ensure the sample-epub-minimal submodule is initialized."
|
||||||
|
)
|
||||||
|
return sample_file
|
||||||
|
|
||||||
|
|
||||||
|
def copy_sample_to_temp(sample_dir: Path) -> Path:
|
||||||
|
temp_root = get_temp_sample_root()
|
||||||
|
temp_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest_dir = temp_root / sample_dir.name
|
||||||
|
if dest_dir.exists():
|
||||||
|
shutil.rmtree(dest_dir)
|
||||||
|
shutil.copytree(sample_dir, dest_dir)
|
||||||
|
return dest_dir
|
||||||
|
|
||||||
|
|
||||||
|
def copy_file_to_temp(sample_file: Path) -> Path:
|
||||||
|
temp_root = get_temp_sample_root()
|
||||||
|
temp_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest_file = temp_root / sample_file.name
|
||||||
|
shutil.copy2(sample_file, dest_file)
|
||||||
|
return dest_file
|
||||||
|
|
||||||
|
|
||||||
|
def zip_sample_directory(sample_dir: Path, output_path: Path) -> Path:
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if output_path.exists():
|
||||||
|
output_path.unlink()
|
||||||
|
|
||||||
|
mimetype_file = sample_dir / "mimetype"
|
||||||
|
with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
if mimetype_file.exists():
|
||||||
|
archive.write(mimetype_file, "mimetype", compress_type=zipfile.ZIP_STORED)
|
||||||
|
for path in sorted(sample_dir.rglob("*")):
|
||||||
|
if path.is_file() and path != mimetype_file:
|
||||||
|
archive.write(path, path.relative_to(sample_dir).as_posix())
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def download_sample_moby_dick(
|
||||||
|
output: Optional[str] = None,
|
||||||
|
with_media_overlay: bool = False,
|
||||||
|
) -> Path:
|
||||||
|
sample_dir = get_sample_directory(with_media_overlay=with_media_overlay)
|
||||||
|
temp_sample_dir = copy_sample_to_temp(sample_dir)
|
||||||
|
default_output = get_temp_sample_root() / f"{sample_dir.name}.epub"
|
||||||
|
output_path = Path(output) if output else default_output
|
||||||
|
output_path = output_path.with_suffix(".epub")
|
||||||
|
return zip_sample_directory(temp_sample_dir, output_path)
|
||||||
|
|
||||||
|
|
||||||
|
def download_minimal_epub_sample(output: Optional[str] = None) -> Path:
|
||||||
|
sample_file = get_minimal_sample_file()
|
||||||
|
temp_sample_file = copy_file_to_temp(sample_file)
|
||||||
|
default_output = get_temp_sample_root() / sample_file.name
|
||||||
|
output_path = Path(output) if output else default_output
|
||||||
|
output_path = output_path.with_suffix(".epub")
|
||||||
|
if temp_sample_file != output_path:
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(temp_sample_file, output_path)
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[list[str]] = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Download the Moby Dick EPUB sample and package it as an EPUB file."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
help="Output EPUB file path. Defaults to .tmp-samples/moby-dick.epub or .tmp-samples/moby-dick-mo.epub.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
output_path = download_sample_moby_dick(
|
||||||
|
output=args.output,
|
||||||
|
with_media_overlay=False,
|
||||||
|
)
|
||||||
|
print(f"Created EPUB sample archive: {output_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main_with_media_overlay(argv: Optional[list[str]] = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Download the Moby Dick EPUB sample with media overlay and package it as an EPUB file."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
help="Output EPUB file path. Defaults to .tmp-samples/moby-dick-mo.epub.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
output_path = download_sample_moby_dick(
|
||||||
|
output=args.output,
|
||||||
|
with_media_overlay=True,
|
||||||
|
)
|
||||||
|
print(f"Created EPUB sample archive: {output_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main_minimal_epub(argv: Optional[list[str]] = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Download the minimal EPUB sample and copy it to an EPUB file."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
help="Output EPUB file path. Defaults to .tmp-samples/minimal.epub.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
output_path = download_minimal_epub_sample(output=args.output)
|
||||||
|
print(f"Created minimal EPUB sample archive: {output_path}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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"])
|
||||||
|
)
|
||||||
+69
-94
@@ -13,14 +13,8 @@ The vendored packages are run in isolated virtual environments when the
|
|||||||
corresponding console script is not available in the current environment.
|
corresponding console script is not available in the current environment.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from . import tts_epub2tts as epub2tts_adapter
|
|
||||||
from . import tts_epub2tts_chatterbox as epub2tts_chatterbox_adapter
|
|
||||||
from . import tts_epub2tts_edge as epub2tts_edge_adapter
|
|
||||||
from . import tts_epub2tts_kokoro as epub2tts_kokoro_adapter
|
|
||||||
from .tts_generic import GenericTTSBackend
|
from .tts_generic import GenericTTSBackend
|
||||||
|
from . import logger, PathLike
|
||||||
|
|
||||||
class AedocwBackend(GenericTTSBackend):
|
class AedocwBackend(GenericTTSBackend):
|
||||||
"""Generic aedocw backend wrapper.
|
"""Generic aedocw backend wrapper.
|
||||||
@@ -35,110 +29,89 @@ class AedocwBackend(GenericTTSBackend):
|
|||||||
but the vendored adapters may still run the package in their own venv if
|
but the vendored adapters may still run the package in their own venv if
|
||||||
the console script is not present.
|
the console script is not present.
|
||||||
"""
|
"""
|
||||||
|
FLAGS = {
|
||||||
|
"speaker": "--speaker",
|
||||||
|
"voice": "--speaker",
|
||||||
|
"language": "--language",
|
||||||
|
"cover": "--cover",
|
||||||
|
}
|
||||||
|
INTERMEDIATE_TXT = True
|
||||||
|
INTERMEDIATE_CALL = GenericTTSBackend._replace_map
|
||||||
|
|
||||||
_CMD_MAP = {
|
def gen_input_flag(self, input_path) -> list[str]:
|
||||||
"epub2tts": "epub2tts",
|
"""Return backend flag for input file or directory."""
|
||||||
"epub2tts-edge": "epub2tts-edge",
|
p = self._normalize_path(input_path)
|
||||||
"epub2tts-chatterbox": "epub2tts-chatterbox",
|
return [str(p)] # epub2tts-edge expects a single path, no flag
|
||||||
"epub2tts-kokoro": "epub2tts-kokoro",
|
|
||||||
}
|
gen_output_flag = None
|
||||||
|
|
||||||
_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):
|
class AedocwEpub2TTS(AedocwBackend):
|
||||||
"""Backend configured for github.com/aedocw/epub2tts."""
|
"""Backend configured for github.com/aedocw/epub2tts."""
|
||||||
|
pass
|
||||||
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):
|
class AedocwEpub2TTSEdge(AedocwBackend):
|
||||||
"""Backend configured for github.com/aedocw/epub2tts-edge."""
|
"""Backend configured for github.com/aedocw/epub2tts-edge."""
|
||||||
|
DEFAULT_SPEAKER = "en-US-AndrewNeural"
|
||||||
|
CMD = ["-c", "from epub2tts_edge import main;main()"]
|
||||||
|
REPO = "epub2tts-edge"
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
backend_cmd: Optional[str] = None,
|
|
||||||
language: Optional[str] = None,
|
|
||||||
voice: Optional[str] = None,
|
class TTSEdge(AedocwEpub2TTSEdge):
|
||||||
):
|
"""Backward-compatible edge backend wrapper."""
|
||||||
super().__init__(
|
pass
|
||||||
repo="epub2tts-edge",
|
|
||||||
backend_cmd=backend_cmd,
|
|
||||||
language=language,
|
|
||||||
voice=voice,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AedocwChatterbox(AedocwBackend):
|
class AedocwChatterbox(AedocwBackend):
|
||||||
"""Backend configured for github.com/aedocw/epub2tts-chatterbox."""
|
"""Backend configured for github.com/aedocw/epub2tts-chatterbox."""
|
||||||
|
|
||||||
def __init__(
|
DEFAULT_SAMPLE = "none"
|
||||||
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):
|
class AedocwKokoro(AedocwBackend):
|
||||||
"""Backend configured for github.com/aedocw/epub2tts-kokoro."""
|
"""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 __init__(
|
|
||||||
self,
|
|
||||||
backend_cmd: Optional[str] = None,
|
def get_speakers(self) -> list[str]:
|
||||||
language: Optional[str] = None,
|
"""Return list of available speakers."""
|
||||||
voice: Optional[str] = None,
|
return ["af_heart", "af_joy", "af_sad", "af_angry", "af_fear", "af_surprise"]
|
||||||
):
|
|
||||||
super().__init__(
|
def gen_speaker_samples(
|
||||||
repo="epub2tts-kokoro",
|
self,
|
||||||
backend_cmd=backend_cmd,
|
samples: list =None,
|
||||||
language=language,
|
output_path: PathLike=None,
|
||||||
voice=voice,
|
) -> 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__ = [
|
__all__ = [
|
||||||
@@ -147,4 +120,6 @@ __all__ = [
|
|||||||
"AedocwEpub2TTSEdge",
|
"AedocwEpub2TTSEdge",
|
||||||
"AedocwChatterbox",
|
"AedocwChatterbox",
|
||||||
"AedocwKokoro",
|
"AedocwKokoro",
|
||||||
|
"TTSEdge",
|
||||||
|
"Epub2TTS",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,14 +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)
|
|
||||||
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
|
||||||
"""Compatibility shim for the edge backend in epub-tts."""
|
|
||||||
|
|
||||||
from .tts_aedocw import AedocwEpub2TTSEdge
|
|
||||||
|
|
||||||
|
|
||||||
class TTSEdge(AedocwEpub2TTSEdge):
|
|
||||||
"""Backward-compatible edge backend wrapper."""
|
|
||||||
|
|
||||||
pass
|
|
||||||
@@ -1,75 +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)
|
|
||||||
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
|
||||||
"""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,62 +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)
|
|
||||||
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
|
||||||
"""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,53 +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)
|
|
||||||
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
|
||||||
"""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,53 +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)
|
|
||||||
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
|
||||||
"""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
|
|
||||||
+131
-42
@@ -8,72 +8,161 @@
|
|||||||
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.
|
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:
|
Classes:
|
||||||
|
|
||||||
GenericTTSBackend:
|
GenericTTSBackend:
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
backend_cmd (str): Command or executable for the backend.
|
backend_cmd (str): Command or executable for the backend.
|
||||||
language (Optional[str]): Language code for TTS output.
|
language (Optional[str]): Language code for TTS output.
|
||||||
voice (Optional[str]): Voice name for TTS output.
|
voice (Optional[str]): Voice name for TTS output.
|
||||||
|
|
||||||
Methods:
|
Methods:
|
||||||
_normalize_path(value: PathLike) -> Path: Normalize a string or Path to Path.
|
_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.
|
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.
|
_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.
|
run(input_source: PathLike, output_dest: PathLike) -> subprocess.CompletedProcess: Execute backend command.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Optional, Callable
|
||||||
|
|
||||||
PathLike = Union[str, Path]
|
|
||||||
|
|
||||||
|
from .utils import build_run_command, ensure_venv
|
||||||
|
from . import logger, PathLike
|
||||||
|
|
||||||
class GenericTTSBackend:
|
class GenericTTSBackend:
|
||||||
def __init__(
|
"""Generic TTS backend for handling text-to-speech conversion.
|
||||||
self,
|
|
||||||
backend_cmd: str,
|
Parameters:
|
||||||
language: Optional[str] = None,
|
backend_cmd (str): Command or executable for the backend.
|
||||||
voice: Optional[str] = None,
|
language (Optional[str]): Language code for TTS output.
|
||||||
):
|
voice (Optional[str]): Voice name for TTS output.
|
||||||
self.backend_cmd = backend_cmd
|
"""
|
||||||
self.language = language
|
REPO: Optional[str] = None
|
||||||
self.voice = voice
|
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:
|
def _normalize_path(self, value: PathLike) -> Path:
|
||||||
if isinstance(value, Path):
|
if isinstance(value, Path):
|
||||||
return value
|
return value.resolve()
|
||||||
return Path(value)
|
else:
|
||||||
|
return Path(value).resolve()
|
||||||
|
|
||||||
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:
|
def gen_input_flag(self, input_path: Path) -> list[str]:
|
||||||
return "--output-dir" if output_path.is_dir() else "--output-file"
|
p = self._normalize_path(input_path)
|
||||||
|
return [self.INPUT_FLAG[p.is_dir()], str(p)]
|
||||||
|
|
||||||
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 = [
|
def gen_output_flag(self, output_path: Path) -> list[str]:
|
||||||
self.backend_cmd,
|
p = self._normalize_path(output_path)
|
||||||
self._input_flag(input_path),
|
return [self.OUTPUT_FLAG[p.is_dir()], str(p)]
|
||||||
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])
|
|
||||||
|
|
||||||
|
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
|
return command
|
||||||
|
|
||||||
|
def run(self,
|
||||||
|
input_source: PathLike,
|
||||||
|
output_dest: PathLike,
|
||||||
|
**kwargs
|
||||||
|
) -> subprocess.CompletedProcess:
|
||||||
|
|
||||||
def run(
|
|
||||||
self, input_source: PathLike, output_dest: PathLike
|
|
||||||
) -> subprocess.CompletedProcess:
|
|
||||||
command = self._build_command(input_source, output_dest)
|
command = self._build_command(input_source, output_dest)
|
||||||
return subprocess.run(command, check=True)
|
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,67 +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)
|
|
||||||
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
|
||||||
"""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
+1
Submodule src/vendor/epub3-samples added at 7651e2002b
+1
Submodule src/vendor/sample-epub-minimal added at 8e7d6cdd91
Reference in New Issue
Block a user