Config interface ok
This commit is contained in:
@@ -15,20 +15,9 @@ except PackageNotFoundError:
|
|||||||
|
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
|
||||||
import pprint
|
|
||||||
|
|
||||||
def log (message, level=None):
|
|
||||||
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)
|
|
||||||
|
|
||||||
PathLike = Union[str, Path]
|
PathLike = Union[str, Path]
|
||||||
|
|
||||||
|
from .logger import logger
|
||||||
|
from .utils import DataDict
|
||||||
|
from .app import App
|
||||||
|
|||||||
+2
-129
@@ -1,138 +1,11 @@
|
|||||||
#!/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
|
|
||||||
"""
|
|
||||||
# stdlib
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from .cli import cli
|
||||||
from argparse import ArgumentParser
|
|
||||||
|
|
||||||
# Pip packages
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
# Local imports
|
|
||||||
from . import tts_aedocw, tts_generic, tts_kokoro
|
|
||||||
from . import log, PathLike
|
|
||||||
from .utils import check_env
|
|
||||||
|
|
||||||
# Setup env
|
|
||||||
load_dotenv() # load environment variables from .env file if present
|
|
||||||
|
|
||||||
# Globals
|
|
||||||
WHICH = ["ffmpeg"] # Neded in $PATH
|
|
||||||
BACKENDS = {
|
|
||||||
# keys are the backend names, values are the corresponding TTS classes
|
|
||||||
"default": tts_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 main(args=None):
|
|
||||||
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__}",
|
|
||||||
)
|
|
||||||
# Input options and processing
|
|
||||||
p.add_argument("-i", "--input",
|
|
||||||
nargs="+", required=True,
|
|
||||||
help="Input EPUB file")
|
|
||||||
p.add_argument("-r", "--replace", action="append", nargs=2,
|
|
||||||
help="Replace text in the intermediate output. "
|
|
||||||
"Specify pairs of old_text new_text. "
|
|
||||||
"Can be used multiple times.")
|
|
||||||
p.add_argument("--check-env",action="store_true",
|
|
||||||
help="Check the runtime environment and exit")
|
|
||||||
p.add_argument("-b", "--backend", default="default",
|
|
||||||
choices=BACKENDS.keys(),
|
|
||||||
help="Backend to use for TTS")
|
|
||||||
|
|
||||||
# Output options
|
|
||||||
p.add_argument("-o", "--output",
|
|
||||||
help="Output audio file", required=True)
|
|
||||||
p.add_argument("-c", "--cover",
|
|
||||||
help="Path to cover image to embed or use "
|
|
||||||
"for output metadata")
|
|
||||||
|
|
||||||
# Speech options
|
|
||||||
p.add_argument("-l", "--language",
|
|
||||||
help="Language to use for TTS (see your "
|
|
||||||
"backend's documentation for available voices)")
|
|
||||||
p.add_argument("-v", "--voice",
|
|
||||||
help="Voice to use for TTS (see your backend's "
|
|
||||||
"documentation for available voices)")
|
|
||||||
p.add_argument("--speed", type=float, default=1.0,
|
|
||||||
help="Playback speed multiplier (default: 1.0) "
|
|
||||||
"(not all backends support this)")
|
|
||||||
p.add_argument("--short-pause", type=int, default=None,
|
|
||||||
help="Short pause duration in milliseconds between "
|
|
||||||
"phrases or sentences (not all backends support this)")
|
|
||||||
p.add_argument("--long-pause", type=int, default=None,
|
|
||||||
help="Long pause duration in milliseconds between "
|
|
||||||
"sections or paragraphs (not all backends support this)")
|
|
||||||
p.add_argument("--notitles", action="store_true",
|
|
||||||
help="Do not read chapter titles")
|
|
||||||
|
|
||||||
# Parse
|
|
||||||
args = p.parse_args(args or sys.argv[1:])
|
|
||||||
setattr(args, 'replace_map',
|
|
||||||
{old: new for old, new in args.replace} if args.replace else None)
|
|
||||||
log(args)
|
|
||||||
|
|
||||||
# Execute actions
|
|
||||||
if args.check_env:
|
|
||||||
check_env()
|
|
||||||
|
|
||||||
engine = BACKENDS[args.backend](**vars(args))
|
|
||||||
if len(args.input) > 1 and not output_dest.is_dir():
|
|
||||||
log(f"Output must be a directory when multiple input files are provided", level="error")
|
|
||||||
sys.exit(1)
|
|
||||||
for file in args.input:
|
|
||||||
log(file)
|
|
||||||
if not Path(file).exists():
|
|
||||||
log(f"Input file {file} does not exist", level="error")
|
|
||||||
sys.exit(1)
|
|
||||||
output_dest = Path(args.output)
|
|
||||||
engine.run(
|
|
||||||
file, output_dest,
|
|
||||||
**vars(args),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(cli())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
#!/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
|
||||||
|
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
|
||||||
|
from . import DataDict
|
||||||
|
|
||||||
|
class App(DataDict):
|
||||||
|
# Globals/Defaults
|
||||||
|
DEFAULT_ENV_FILE=".env"
|
||||||
|
ENV_FILE_OVERRIDE=False
|
||||||
|
WHICH = ["ffmpeg"] # Needed in $PATH
|
||||||
|
DEBUG = False
|
||||||
|
LOGLEVEL = logging.INFO
|
||||||
|
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, *args, **kwargs):
|
||||||
|
super().__init__(*args,**kwargs)
|
||||||
|
self._argv = sys.argv
|
||||||
|
|
||||||
|
self._args_list = args if args is not None else self._argv
|
||||||
|
|
||||||
|
|
||||||
|
# Setup logger
|
||||||
|
self.logger = logger
|
||||||
|
self.logger.setLevel(App.LOGLEVEL)
|
||||||
|
if App.DEBUG or "--debug" in self._args_list:
|
||||||
|
self.debug = True
|
||||||
|
self.loglevel = logging.DEBUG
|
||||||
|
logger.info("Loglevel = DEBUG")
|
||||||
|
if "--loglevel" in self._args_list:
|
||||||
|
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.")
|
||||||
|
|
||||||
|
for i, a in enumerate(self._args_list):
|
||||||
|
if a.startswith("--"):
|
||||||
|
match a:
|
||||||
|
case "--env_file":
|
||||||
|
if self.get("env_file", None):
|
||||||
|
self.env_file = self._args_list[i+1]
|
||||||
|
case "--loglevel":
|
||||||
|
self.loglevel = self._args_list[i+1]
|
||||||
|
case "--verbose":
|
||||||
|
self.loglevel = logging.INFO
|
||||||
|
case "--logfile":
|
||||||
|
self.logfile = self._args_list[i+1]
|
||||||
|
|
||||||
|
for var_name in ["env_file","loglevel","logfile"]:
|
||||||
|
flag = f"--{var_name}="
|
||||||
|
if a.startswith(flag):
|
||||||
|
self[var_name] = a.replace(flag, "")
|
||||||
|
|
||||||
|
if "env_file" not in self._data:
|
||||||
|
self._data["env_file"] = App.DEFAULT_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()]
|
||||||
|
|
||||||
|
# Set loglevel from laste --loglevel flag found in args/sys.argv
|
||||||
|
if "loglevel" not in self._data:
|
||||||
|
self.loglevel = self.LOGLEVEL
|
||||||
|
self.logger.setLevel(self.loglevel)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
if self._parsed_args.loglevel:
|
||||||
|
self.loglevel = self._parsed_args.loglevel
|
||||||
|
self.logger.setLevel(self.loglevel)
|
||||||
|
elif self._parsed_args.verbose:
|
||||||
|
self.loglevel = logging.INFO
|
||||||
|
self.logger.setLevel(self.loglevel)
|
||||||
|
|
||||||
|
if getattr(self, "logfile", None) or getattr(self._parsed_args, "logfile", None):
|
||||||
|
logfile = getattr(self, "logfile", None) or self._parsed_args.logfile
|
||||||
|
fh = logging.FileHandler(logfile)
|
||||||
|
fh.setLevel(self.loglevel)
|
||||||
|
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||||
|
self.logger.addHandler(fh)
|
||||||
|
|
||||||
|
# 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.info("Paths provided as input: %d", len(self.input))
|
||||||
|
for i, input_str in enumerate(self.input):
|
||||||
|
self.info("[Input %d] %s", i+1, input_str)
|
||||||
|
input_path = App.path(input_str)
|
||||||
|
if not input_path.exists():
|
||||||
|
self.warning("Skipping path (do not exist): '%s'.", input_path)
|
||||||
|
continue
|
||||||
|
if input_path.is_dir():
|
||||||
|
self.input_dir.append(input_path)
|
||||||
|
self.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.info("Added file to queue: %s", self.input_files[input_str])
|
||||||
|
del self._data["input"]
|
||||||
|
|
||||||
|
|
||||||
|
# Sanitize replace_map
|
||||||
|
self.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[args.backend](**vars(args))
|
||||||
|
if len(args.input) > 1 and not output_dest.is_dir():
|
||||||
|
self.logger.error(f"Output must be a directory when multiple input files are provided")
|
||||||
|
sys.exit(1)
|
||||||
|
for file in args.input:
|
||||||
|
self.logger.info(file)
|
||||||
|
if not Path(file).exists():
|
||||||
|
self.logger.error(f"Input file %s does not exist", file)
|
||||||
|
sys.exit(1)
|
||||||
|
output_dest = Path(args.output)
|
||||||
|
|
||||||
|
# Log functions
|
||||||
|
def log(self, *args,**kwargs):
|
||||||
|
self.logger.log(self.loglevel, *args, **kwargs)
|
||||||
|
|
||||||
|
# 10=DEBUG
|
||||||
|
def debug(self, *args,**kwargs):
|
||||||
|
self.logger.debug(*args, **kwargs)
|
||||||
|
|
||||||
|
# 20=INFO
|
||||||
|
def info(self, *args,**kwargs):
|
||||||
|
self.logger.info(*args, **kwargs)
|
||||||
|
|
||||||
|
# 30=WARN/WARNING
|
||||||
|
def warning(self, *args,**kwargs):
|
||||||
|
self.logger.warning(*args, **kwargs)
|
||||||
|
|
||||||
|
# 40=ERROR
|
||||||
|
def error(self, *args,**kwargs):
|
||||||
|
self.logger.error(*args, **kwargs)
|
||||||
|
|
||||||
|
# 50=CRITICAL/FATAL
|
||||||
|
def critical(self, *args,**kwargs):
|
||||||
|
self.logger.critical(*args, **kwargs)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
def get_parser(self):
|
||||||
|
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", action="store_true",
|
||||||
|
help="Enable debug logging and verbose diagnostics")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def path(fpath: PathLike = None):
|
||||||
|
return Path(fpath).resolve()
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
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,38 @@
|
|||||||
|
#!/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
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import pprint
|
||||||
|
|
||||||
|
# Globals
|
||||||
|
logging.basicConfig(
|
||||||
|
# 50=CRITICAL/FATAL, 40=ERROR, 30=WARN/WARNING, 20=INFO, 10=DEBUG, 0=NOTSET
|
||||||
|
# loglevel_map = logging.getLevelNamesMapping()
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Logging utils
|
||||||
|
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)
|
||||||
@@ -25,7 +25,7 @@ from pydub import AudioSegment
|
|||||||
from mutagen import mp4
|
from mutagen import mp4
|
||||||
# Local imports
|
# Local imports
|
||||||
from .tts_generic import GenericTTSBackend
|
from .tts_generic import GenericTTSBackend
|
||||||
from . import log, PathLike
|
from . import logger, PathLike
|
||||||
from .preprocess import preprocess_book
|
from .preprocess import preprocess_book
|
||||||
|
|
||||||
def generate_metadata(files, author, title, chapter_titles):
|
def generate_metadata(files, author, title, chapter_titles):
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from nltk.tokenize import sent_tokenize
|
|||||||
|
|
||||||
# Local imports
|
# Local imports
|
||||||
from .tts_generic import GenericTTSBackend
|
from .tts_generic import GenericTTSBackend
|
||||||
from . import log, PathLike
|
from . import logger, PathLike
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ corresponding console script is not available in the current environment.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .tts_generic import GenericTTSBackend
|
from .tts_generic import GenericTTSBackend
|
||||||
from . import log, PathLike
|
from . import logger, PathLike
|
||||||
|
|
||||||
class AedocwBackend(GenericTTSBackend):
|
class AedocwBackend(GenericTTSBackend):
|
||||||
"""Generic aedocw backend wrapper.
|
"""Generic aedocw backend wrapper.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from pathlib import Path
|
|||||||
from typing import Optional, Callable
|
from typing import Optional, Callable
|
||||||
|
|
||||||
from .utils import build_run_command, ensure_venv
|
from .utils import build_run_command, ensure_venv
|
||||||
from . import log, PathLike
|
from . import logger, PathLike
|
||||||
|
|
||||||
class GenericTTSBackend:
|
class GenericTTSBackend:
|
||||||
"""Generic TTS backend for handling text-to-speech conversion.
|
"""Generic TTS backend for handling text-to-speech conversion.
|
||||||
@@ -67,7 +67,7 @@ class GenericTTSBackend:
|
|||||||
ENV = None # subclasses may override this to set environment variables for the backend
|
ENV = None # subclasses may override this to set environment variables for the backend
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
log(kwargs)
|
logger.info(kwargs)
|
||||||
for k,v in kwargs.items():
|
for k,v in kwargs.items():
|
||||||
setattr(self, k, v)
|
setattr(self, k, v)
|
||||||
if hasattr(self, 'CWD') and self.CWD is not None:
|
if hasattr(self, 'CWD') and self.CWD is not None:
|
||||||
@@ -77,7 +77,7 @@ class GenericTTSBackend:
|
|||||||
if not self.CWD.is_dir():
|
if not self.CWD.is_dir():
|
||||||
self.CWD = self.CWD.parent.resolve()
|
self.CWD = self.CWD.parent.resolve()
|
||||||
self.ENV = os.environ.copy()
|
self.ENV = os.environ.copy()
|
||||||
#log(f"SELF:{self.__dict__}")
|
#logger.info(f"SELF:{self.__dict__}")
|
||||||
|
|
||||||
|
|
||||||
def _normalize_path(self, value: PathLike) -> Path:
|
def _normalize_path(self, value: PathLike) -> Path:
|
||||||
@@ -123,7 +123,7 @@ class GenericTTSBackend:
|
|||||||
) -> subprocess.CompletedProcess:
|
) -> subprocess.CompletedProcess:
|
||||||
|
|
||||||
command = self._build_command(input_source, output_dest)
|
command = self._build_command(input_source, output_dest)
|
||||||
log(command)
|
logger.info(command)
|
||||||
|
|
||||||
# PREPROCESSING STEP: If INTERMEDIATE_TXT is True, run the command to generate intermediate text first
|
# PREPROCESSING STEP: If INTERMEDIATE_TXT is True, run the command to generate intermediate text first
|
||||||
if not input_source.endswith(".txt"):
|
if not input_source.endswith(".txt"):
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from pydub import AudioSegment
|
|||||||
from mutagen import mp4
|
from mutagen import mp4
|
||||||
# Local imports
|
# Local imports
|
||||||
from .tts_generic import GenericTTSBackend
|
from .tts_generic import GenericTTSBackend
|
||||||
from . import log, PathLike
|
from . import logger, PathLike
|
||||||
from .preprocess import preprocess_book
|
from .preprocess import preprocess_book
|
||||||
|
|
||||||
class KokoroBackend(GenericTTSBackend):
|
class KokoroBackend(GenericTTSBackend):
|
||||||
@@ -56,7 +56,7 @@ class KokoroBackend(GenericTTSBackend):
|
|||||||
) -> subprocess.CompletedProcess:
|
) -> subprocess.CompletedProcess:
|
||||||
|
|
||||||
command = self._build_command(input_source, output_dest)
|
command = self._build_command(input_source, output_dest)
|
||||||
log(command)
|
logger.info(command)
|
||||||
|
|
||||||
# # PREPROCESSING STEP: If INTERMEDIATE_TXT is True, run the command to generate intermediate text first
|
# # PREPROCESSING STEP: If INTERMEDIATE_TXT is True, run the command to generate intermediate text first
|
||||||
# if not input_source.endswith(".txt"):
|
# if not input_source.endswith(".txt"):
|
||||||
|
|||||||
+99
-19
@@ -11,7 +11,105 @@ import sys
|
|||||||
import venv
|
import venv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from . import log, PathLike
|
from collections.abc import MutableMapping
|
||||||
|
|
||||||
|
from . import logger, PathLike
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def get_vendor_root() -> Path:
|
||||||
return Path(__file__).resolve().parents[1] / "vendor"
|
return Path(__file__).resolve().parents[1] / "vendor"
|
||||||
@@ -72,21 +170,3 @@ def build_run_command(
|
|||||||
# else str(venv_path / "bin" / backend_cmd)
|
# else str(venv_path / "bin" / backend_cmd)
|
||||||
# )
|
# )
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def check_env(self, which=None):
|
|
||||||
if not which:
|
|
||||||
return None
|
|
||||||
if isinstance(which, str):
|
|
||||||
if " " in which:
|
|
||||||
which = which.split()
|
|
||||||
which = [which]
|
|
||||||
for bin in which:
|
|
||||||
p = shutil.which(bin)
|
|
||||||
if not p:
|
|
||||||
log(f"{bin} is either NOT installed or "
|
|
||||||
"NOT in your system PATH.")
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
log(f"{bin} found at: {p}")
|
|
||||||
return True
|
|
||||||
Reference in New Issue
Block a user