aboutsummaryrefslogtreecommitdiff
path: root/audiobook.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-27 19:00:44 -0400
committerhistoria <historiavg@proton.me>2026-08-27 19:00:44 -0400
commit5611f709367677b77a482aa8e396fd3bc6a0ab26 (patch)
treec19f8ffa5a199d65ebb2c44ffaa7fc3feff3f078 /audiobook.py
parent5ecf7711f3457587a00859b04e239d1d7de3ca2f (diff)
downloadtts-audiobook-generator-5611f709367677b77a482aa8e396fd3bc6a0ab26.tar.gz
feat: flags for converting individual book files --input-book --output-book
Diffstat (limited to 'audiobook.py')
-rwxr-xr-xaudiobook.py124
1 files changed, 121 insertions, 3 deletions
diff --git a/audiobook.py b/audiobook.py
index 03ba7c9..7bdd989 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -5,7 +5,9 @@ Converts TXT, PDF and EPUB files into audiobooks using a local TTS server.
Run with no arguments in a terminal for the full TUI (set up backends,
process the input directory); pass flags to script a conversion directly.
-Edit app/converter/config.py to change voice and processing settings.
+--input-file/--output-file convert one individual book file instead of a
+directory (the two flag pairs are mutually exclusive). Edit
+app/converter/config.py to change voice and processing settings.
"""
import argparse
@@ -48,9 +50,23 @@ from converter.clients import (
from converter.converter import (
AUDIO_FORMATS,
AudiobookConverter,
+ SUPPORTED_FORMATS,
setup_directories,
setup_logging,
)
+from converter.converter import BASE_DIR as _BASE_DIR
+
+
+def resolve_book_path(path: Path) -> Path:
+ """Resolve a CLI book/output-file path argument.
+
+ "~" expands to the home directory and relative paths resolve against
+ the project root, mirroring how the --input/--output directory flags
+ are resolved, so the converter behaves the same from any working
+ directory.
+ """
+ resolved = Path(path).expanduser()
+ return resolved if resolved.is_absolute() else _BASE_DIR / resolved
def convert(backend: str = None, voice: str = None, clone: str = None,
@@ -60,6 +76,7 @@ def convert(backend: str = None, voice: str = None, clone: str = None,
model_id: str = None, instructions: str = None,
request_options: dict = None, input_dir: Path = None,
output_dir: Path = None, api_url: str = None,
+ input_file: Path = None, output_file: Path = None,
progress=None, cancel=None, confirm=None,
book_files=None, planned=None) -> int:
"""Run one conversion pass with explicit options (used by the CLI and hub).
@@ -74,6 +91,14 @@ def convert(backend: str = None, voice: str = None, clone: str = None,
for the selected backend (used by the hub's "[remote]" entries and
--api-url).
+ INPUT_FILE converts a single book file instead of scanning the
+ input folder (the CLI validates it and resolves relative paths
+ against the project root). OUTPUT_FILE, which requires INPUT_FILE,
+ redirects that book's audio to an explicit base path: its parent
+ folder receives the files and its stem is the base output name
+ (chapter files gain _NN_Title suffixes), used verbatim without the
+ narrator tag.
+
PROGRESS (a callback taking an event dict), CANCEL (a
threading.Event the caller sets to stop between requests), CONFIRM
(a (message, default) -> bool callback replacing the console
@@ -88,6 +113,22 @@ def convert(backend: str = None, voice: str = None, clone: str = None,
input_dir = config.INPUT_DIR if input_dir is None else input_dir
output_dir = config.OUTPUT_DIR if output_dir is None else output_dir
request_options = request_options or {}
+
+ # --input-file converts one book instead of scanning the input
+ # folder; --output-file (requires it) sends that book's audio to an
+ # explicit base path: the parent folder receives the files and the
+ # stem is the base name, so redirect the output folder there.
+ single_book: Path = None
+ output_name_override: str = None
+ if input_file is not None or output_file is not None:
+ if output_file is not None and input_file is None:
+ raise ValueError("output_file requires input_file")
+ single_book = resolve_book_path(input_file)
+ if output_file is not None:
+ output_file = resolve_book_path(output_file)
+ output_dir = output_file.parent
+ output_name_override = output_file.stem
+
_converter_mod.BOOKS_FOLDER = _converter_mod.resolve_dir(
input_dir, "input")
_converter_mod.AUDIOBOOKS_FOLDER = _converter_mod.resolve_dir(
@@ -114,6 +155,8 @@ def convert(backend: str = None, voice: str = None, clone: str = None,
backend=backend, voice=voice, voice_mode=voice_mode,
voice_clone_ref_audio=clone, output_format=output_format,
instructions=instructions, confirm=confirm,
+ book_files=[single_book] if single_book is not None else None,
+ output_name=output_name_override,
)
if not book_files:
print("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file "
@@ -199,6 +242,9 @@ Examples:
# Use the faster-qwen3-tts server (voice cloning, configured server-side)
python audiobook.py --backend faster [--voice NAME]
+
+ # Convert one specific book file instead of scanning the input directory
+ python audiobook.py --input-file books/dune.epub --output-file out/dune.mp3
"""
)
@@ -234,7 +280,7 @@ Examples:
"app/converter/config.py.")
)
parser.add_argument(
- "--format", choices=list(AUDIO_FORMATS), default=config.AUDIO_FORMAT,
+ "--format", choices=list(AUDIO_FORMATS), default=None,
help=f"Output container format (default: {config.AUDIO_FORMAT}). m4b uses AAC audio."
)
parser.add_argument(
@@ -251,6 +297,24 @@ Examples:
"relative paths resolve against the project root.")
)
parser.add_argument(
+ "--input-file", type=Path, metavar="FILE", default=None,
+ help=("Convert one specific book file (.txt/.pdf/.epub) instead of "
+ "scanning a directory for books; cannot be combined with "
+ "--input. Relative paths resolve against the project root. "
+ "Without --output-file, the audiobook goes to the output "
+ "directory under its usual narrator-tagged name.")
+ )
+ parser.add_argument(
+ "--output-file", type=Path, metavar="FILE", default=None,
+ help=("Base path for the audiobook produced from --input-file "
+ "(requires it; cannot be combined with --output): the file's "
+ "parent folder receives the audio and its stem is the base "
+ "output name, without the narrator tag. Chapter files are "
+ "written as STEM_NN_Title.ext. The extension must match the "
+ "output format (--format or the AUDIO_FORMAT config setting) "
+ "or the run stops without converting.")
+ )
+ parser.add_argument(
"--single-file", action="store_true",
help=("Combine all chapters into a single audio file. By default books with "
"chapters (e.g. EPUB) are converted to one file per chapter. "
@@ -337,9 +401,62 @@ Examples:
if bad_speed:
parser.error(f"--speed must be a positive number (got {speed!r})")
+ # The directory flags and the single-book flags are two different
+ # ways to choose what to convert and where it goes; mixing a pair
+ # is always a mistake, so stop here and explain both flags.
+ if args.input is not None and args.input_file is not None:
+ parser.error(
+ "--input and --input-file cannot be used together: --input "
+ "converts every supported book found in a directory, while "
+ "--input-file converts one specific book file. Pass only one "
+ "of the two.")
+ if args.output is not None and args.output_file is not None:
+ parser.error(
+ "--output and --output-file cannot be used together: --output "
+ "names the directory that receives finished audiobooks, while "
+ "--output-file names the file produced from --input-file (its "
+ "parent folder + stem). Pass only one of the two.")
+ if args.output_file is not None and args.input_file is None:
+ parser.error(
+ "--output-file requires --input-file: it names the output of "
+ "one specific book, and there is nothing to attach it to when "
+ "converting a whole directory (use --output instead).")
+
if args.input is not None and not args.input.is_dir():
parser.error(f"--input: no such directory: {args.input}")
+ # An explicit --format overrides the config AUDIO_FORMAT setting;
+ # the resolved format is what --output-file's extension must match.
+ output_format = args.format or config.AUDIO_FORMAT
+
+ input_file = None
+ if args.input_file is not None:
+ input_file = resolve_book_path(args.input_file)
+ if not input_file.is_file():
+ parser.error(f"--input-file: no such book file: {input_file}")
+ if input_file.suffix.lower() not in SUPPORTED_FORMATS:
+ parser.error(
+ f"--input-file: unsupported book format "
+ f"{input_file.suffix or '(no extension)'} - want one of: "
+ f"{', '.join(SUPPORTED_FORMATS)}")
+
+ output_file = None
+ if args.output_file is not None:
+ output_file = resolve_book_path(args.output_file)
+ extension = output_file.suffix.lower().lstrip(".")
+ if extension and extension not in AUDIO_FORMATS:
+ parser.error(
+ f"--output-file: unsupported extension .{extension} - want "
+ f"one of: .{', .'.join(AUDIO_FORMATS)} (or drop the "
+ "extension to use the output format)")
+ if extension and extension != output_format:
+ parser.error(
+ f"--output-file: extension .{extension} does not match the "
+ f"output format {output_format} (from --format or the "
+ "AUDIO_FORMAT setting in app/converter/config.py) - pass "
+ f"--format {extension} or name the file "
+ f"{output_file.stem}.{output_format}")
+
if args.backend == BACKEND_FASTER:
if args.clone:
print("[WARNING] --clone is ignored with --backend faster: that backend "
@@ -425,11 +542,12 @@ Examples:
backend=args.backend, voice=args.voice, clone=args.clone,
transcription=args.transcription, no_transcription=args.no_transcription,
language=args.language, speed=args.speed, single_file=args.single_file,
- output_format=args.format, debug=args.debug or None,
+ output_format=output_format, debug=args.debug or None,
model_id=args.model, instructions=args.instructions,
request_options=request_options,
input_dir=args.input, output_dir=args.output,
api_url=api_url,
+ input_file=input_file, output_file=output_file,
))