Merge branch 'dev'

This commit is contained in:
2026-08-12 08:56:27 -03:00
12 changed files with 4108 additions and 70 deletions
+3 -17
View File
@@ -13,22 +13,8 @@ except PackageNotFoundError:
# package is not installed
pass
from typing import Union
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]
from .logger import logger, loglevel_map, get_logger
from .utils import DataDict, PathLike, path
from .app import App
+5 -24
View File
@@ -1,31 +1,10 @@
#!/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
<<<<<<< HEAD
from pathlib import Path
from argparse import ArgumentParser
@@ -132,8 +111,10 @@ def main(args=None):
**vars(args),
)
=======
from .cli import cli
>>>>>>> dev
if __name__ == "__main__":
sys.exit(main())
sys.exit(cli())
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html)
"""config.py
The industry-standard hierarchy for application configuration is:
1. Command-Line Arguments (argparse) (Highest priority – explicit runtime overrides).
2. Environment Variables (os.environ) (Medium priority – deployment or session-specific configuration).
3. Configuration Files (e.g., .env, .ini, .yaml files)
4. Hardcoded Defaults (Lowest priority – fallback safety net)
"""
# stdlib
from argparse import ArgumentParser
from collections import UserDict
from pathlib import Path
from typing import Optional
import logging
import os
import re
import shutil
import sys
# Pip packages
from dotenv import load_dotenv
# Local imports
from . import tts_aedocw, tts_generic, tts_kokoro
from . import logger, PathLike, loglevel_map
from . import DataDict
class App():
WHICH = ["ffmpeg"] # Needed in $PATH
BACKENDS = {
# keys are the backend names, values are the corresponding TTS classes
"default": tts_generic.GenericTTSBackend,
"edge": tts_aedocw.TTSEdge,
# aedocw-backed implementations — these expect the corresponding
# package "console scripts"/entrypoints to be available in the
# environment (or an explicit backend_cmd to be provided).
"epub2tts": tts_aedocw.AedocwEpub2TTS,
"epub2tts-edge": tts_aedocw.AedocwEpub2TTSEdge,
"epub2tts-chatterbox": tts_aedocw.AedocwChatterbox,
"epub2tts-kokoro": tts_aedocw.AedocwKokoro,
"generic-epub2tts": tts_aedocw.Epub2TTS,
"kokoro": tts_kokoro.KokoroBackend,
}
def __init__(self,
input_str: Optional[str|Path|list[str|Path]],
output_str: Optional[str|Path],
backend: str,
):
# Validate and sanitize output
self.output_path = App.path(self.output)
if len(self.input) > 1 and not self.output_path.is_dir():
self.logger.error("Output must be a directory when multiple input files are provided. Invalid: '%s'", self.output_path)
sys.exit(1)
del self._data["output"]
# Validate and sanitize input
self.input_files = {}
self.input_dirs = []
self.logger.info("Paths provided as input: %d", len(self.input))
for i, input_str in enumerate(self.input):
self.logger.info("[Input %d] %s", i+1, input_str)
input_path = App.path(input_str)
if not input_path.exists():
self.logger.warning("Skipping path (do not exist): '%s'.", input_path)
continue
if input_path.is_dir():
self.input_dir.append(input_path)
self.logger.info("Added directory to queue: '%s'", input_path)
else:
self.input_files[input_str] = {"path":input_path}
self.input_files[input_str]["filename"] = self.input_files[input_str]["path"].name
self.input_files[input_str]["stem"] = self.input_files[input_str]["path"].stem
self.input_files[input_str]["suffix"] = self.input_files[input_str]["path"].suffix
#self.input_files[input_str]["filetype"] = ebook_meta self.input_files[input_str]["path"]
self.logger.info("Added file to queue: %s", self.input_files[input_str])
del self._data["input"]
# Sanitize replace_map
self.logger.debug(self.replace)
if self.replace and len(self.replace) % 2 == 0:
self.replace_map = { old: new for old, new in self.replace}
del self.replace
# print args
self.logger.debug(self)
# Execute actions
if self._parsed_args.check_env:
self.check_env()
self.engine = App.BACKENDS[self.backend](self)
# Utility methods
def check_env(self, which=None):
"""Check anvironment for necessary binaries"""
if not which:
which = self.WHICH
if isinstance(which, str):
if " " in which:
which = which.split()
which = [which]
for bin in which:
if sys.platform == "win32":
p = shutil.which(bin + ".exe")
else:
p = shutil.which(bin)
if not p:
self.logger.error(f"%s is either NOT installed or "
"NOT in your system PATH.", bin)
return False
else:
self.logger.info(f"%s found at: %s", bin, p)
return True
+154
View File
@@ -0,0 +1,154 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa
# Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html)
"""Command-line interface for converting EPUB to audio using TTS.
This module provides a command-line interface (CLI) for converting EPUB files to audio using text-to-speech (TTS) backends. It allows users to specify the input EPUB file, output audio file, language, voice, and backend to use for TTS conversion.
usage: epub-tts [-h] [--version] -i INPUT -o OUTPUT [-l LANGUAGE]
[-v VOICE] [-b {default,edge,epub2tts,epub2tts-edge,epub2tts-chatterbox,epub2tts-kokoro}]
optional arguments:
-h, --help show this help message and exit
--version Show version and exit
-i INPUT, --input INPUT
Input EPUB file
-o OUTPUT, --output OUTPUT
Output audio file
-l LANGUAGE, --language LANGUAGE
Language to use for TTS
-v VOICE, --voice Voice to use for TTS
-b {default,edge,epub2tts,epub2tts-edge,epub2tts-chatterbox,epub2tts-kokoro}, --backend {default,edge,epub2tts,epub2tts-edge,epub2tts-chatterbox,epub2tts-kokoro}
Backend to use for TTS
"""
# stdlib
import os
import sys
from pathlib import Path
from argparse import ArgumentParser
# Local imports
from . import App
from . import logger, get_logger
# Globals/Defaults
DEFAULT_ENV_FILE=".env"
ENV_FILE_OVERRIDE=False
DEBUG = False
LOGLEVEL = "WARNING"
def get_parser():
p = ArgumentParser(description="Convert EPUB to audio using TTS")
p.add_argument(
"--version",
help="Show version and exit",
action="version",
version=f"%(prog)s {__import__('epub_tts').__version__}")
# General options
p.add_argument(
"--debug", dest="debug_level", action="store_true",
help="Enable debug logging and verbose diagnostics")
p.add_argument(
"--verbose", action="store_true",
help="Enable info-level logging")
p.add_argument(
"--loglevel",
type=str.upper, default=logging.INFO,
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set logging level, from most verbose to least verbose:")
p.add_argument(
"--logfile", default=None,
help="Write logs to the specified file")
# Input options and processing
p.add_argument(
"-i", "--input",
nargs="+", required=True,
help="Input EPUB file")
p.add_argument(
"-r", "--replace", action="append", nargs=2,
help="Replace text in the intermediate output. "
"Specify pairs of old_text new_text. "
"Can be used multiple times.")
p.add_argument(
"--check-env",action="store_true",
help="Check the runtime environment and exit")
p.add_argument(
"-b", "--backend", default="default",
choices=App.BACKENDS.keys(),
help="Backend to use for TTS")
# Output options
p.add_argument(
"-o", "--output",
help="Output audio file", required=True)
p.add_argument(
"-c", "--cover",
help="Path to cover image to embed or use "
"for output metadata")
# Speech options
p.add_argument(
"-l", "--language",
help="Language to use for TTS (see your "
"backend's documentation for available voices)")
p.add_argument(
"-v", "--voice",
help="Voice to use for TTS (see your backend's "
"documentation for available voices)")
p.add_argument(
"--speed", type=float, default=1.0,
help="Playback speed multiplier (default: 1.0) "
"(not all backends support this)")
p.add_argument(
"--short-pause", type=int, default=None,
help="Short pause duration in milliseconds between "
"phrases or sentences (not all backends support this)")
p.add_argument(
"--long-pause", type=int, default=None,
help="Long pause duration in milliseconds between "
"sections or paragraphs (not all backends support this)")
p.add_argument(
"--notitles", action="store_true",
help="Do not read chapter titles")
return p
def setup_app(cmdline:str|list[str]=None):
logger = get_logger()
parser = get_parser()
parsed_args = p.parse_args(cmdline)
if "env_file" not in self._data:
self._data["env_file"] = App.DEFAULT_ENV_FILE
self.logger.debug(self._data["env_file"])
# for path_flag in ["env_file"]:
# if not Path(self[path_flag]).exists():
# self.logger.warning(f"Could not read %s. Ignoring '%s' and using '%s'", path_flag, self[path_flag], App.DEFAULT_ENV_FILE)
# self[path_flag] = App["DEFAULT_" + path_flag.upper()]
# load .env/env_file
load_dotenv(self.env_file, override=os.environ.get("ENV_FILE_OVERRIDE",App.ENV_FILE_OVERRIDE))
# Now we can call argparser with correct defaults and logger/loglevel
# Get ArgumentParser and pased *args or read from sys.argv
self._parsed_args = self.get_parser().parse_args(args or sys.argv[1:])
for k,v in self._parsed_args.__dict__.items():
self[k] = v
def cli(args=None):
app = App()
app.engine(**vars(args))
app.engine.run(
app.file, app.output_dest,
**vars(args),
)
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright (c) 2025-2026 Renato Xavier da Silveira Rosa
# See [LICENSE](./LICENSE) or [BSD-3-Clause-Clear](https://spdx.org/licenses/BSD-3-Clause-Clear.html)
# ============================================================
# Logging
# ============================================================
from pathlib import Path
import enum
import logging
import os
import re
import sys
import pprint
# 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,
)
logger = logging.getLogger(__name__)
loglevel_map = logging.getLevelNamesMapping()
# 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)
def get_level(
cmdline: str|list[str] = None,
verbose: bool = False,
debug: bool = False,
quiet: bool = False,
default: 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:
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"]:
config.logger.debug("self.loglevel='%s'(%d) > INFO", config.loglevel, loglevel_map[config.loglevel])
config.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
+1 -1
View File
@@ -25,7 +25,7 @@ from pydub import AudioSegment
from mutagen import mp4
# Local imports
from .tts_generic import GenericTTSBackend
from . import log, PathLike
from . import logger, PathLike
from .preprocess import preprocess_book
def generate_metadata(files, author, title, chapter_titles):
+1 -1
View File
@@ -28,7 +28,7 @@ from nltk.tokenize import sent_tokenize
# Local imports
from .tts_generic import GenericTTSBackend
from . import log, PathLike
from . import logger, PathLike
+1 -1
View File
@@ -14,7 +14,7 @@ corresponding console script is not available in the current environment.
"""
from .tts_generic import GenericTTSBackend
from . import log, PathLike
from . import logger, PathLike
class AedocwBackend(GenericTTSBackend):
"""Generic aedocw backend wrapper.
+4 -4
View File
@@ -35,7 +35,7 @@ from pathlib import Path
from typing import Optional, Callable
from .utils import build_run_command, ensure_venv
from . import log, PathLike
from . import logger, PathLike
class GenericTTSBackend:
"""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
def __init__(self, **kwargs):
log(kwargs)
logger.info(kwargs)
for k,v in kwargs.items():
setattr(self, k, v)
if hasattr(self, 'CWD') and self.CWD is not None:
@@ -77,7 +77,7 @@ class GenericTTSBackend:
if not self.CWD.is_dir():
self.CWD = self.CWD.parent.resolve()
self.ENV = os.environ.copy()
#log(f"SELF:{self.__dict__}")
#logger.info(f"SELF:{self.__dict__}")
def _normalize_path(self, value: PathLike) -> Path:
@@ -123,7 +123,7 @@ class GenericTTSBackend:
) -> subprocess.CompletedProcess:
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
if not input_source.endswith(".txt"):
+2 -2
View File
@@ -30,7 +30,7 @@ from pydub import AudioSegment
from mutagen import mp4
# Local imports
from .tts_generic import GenericTTSBackend
from . import log, PathLike
from . import logger, PathLike
from .preprocess import preprocess_book
class KokoroBackend(GenericTTSBackend):
@@ -58,7 +58,7 @@ class KokoroBackend(GenericTTSBackend):
) -> subprocess.CompletedProcess:
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
# if not input_source.endswith(".txt"):
+103 -20
View File
@@ -10,8 +10,109 @@ import shutil
import sys
import venv
from pathlib import Path
from typing import Optional
from . import log, PathLike
from typing import Optional, Union
from collections.abc import MutableMapping
from . import logger, 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"
@@ -72,21 +173,3 @@ def build_run_command(
# 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