aboutsummaryrefslogtreecommitdiff
path: root/audiobook.py
diff options
context:
space:
mode:
Diffstat (limited to 'audiobook.py')
-rwxr-xr-xaudiobook.py229
1 files changed, 106 insertions, 123 deletions
diff --git a/audiobook.py b/audiobook.py
index 6ad056c..da67ac7 100755
--- a/audiobook.py
+++ b/audiobook.py
@@ -3,6 +3,8 @@
TTS Audiobook Generator
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 converter/config.py to change voice and processing settings.
"""
@@ -35,17 +37,92 @@ from converter.tts import (
)
+def convert(backend: str = None, voice: str = None, clone: str = None,
+ transcription: str = None, no_transcription: bool = False,
+ language: str = None, speed: float = 1.0, single_file: bool = False,
+ output_format: str = None, debug: bool = False, chunk: bool = False,
+ model_id: str = None, instructions: str = None,
+ request_options: dict = None) -> int:
+ """Run one conversion pass with explicit options (used by the CLI and hub).
+
+ Returns the process exit code (0 on success, 1 on failure, 130 on
+ Ctrl-C). BACKEND defaults to config.BACKEND, OUTPUT_FORMAT to
+ config.AUDIO_FORMAT. LANGUAGE is already-normalized where required.
+ """
+ backend = backend or config.BACKEND
+ output_format = output_format or config.AUDIO_FORMAT
+ request_options = request_options or {}
+ setup_logging(debug=debug)
+ setup_directories()
+
+ if backend == BACKEND_FASTER:
+ voice_mode = VOICE_MODE_CLONE
+ elif backend == BACKEND_AUDIOCPP:
+ voice_mode = VOICE_MODE_CLONE if voice else VOICE_MODE_CUSTOM
+ else:
+ voice_mode = VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM
+
+ book_files, planned = AudiobookConverter.preflight_overwrites(
+ backend=backend, voice=voice, voice_mode=voice_mode,
+ voice_clone_ref_audio=clone, output_format=output_format,
+ instructions=instructions,
+ )
+ if not book_files:
+ print("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file "
+ "to the input folder and run again.")
+ return 0
+ if not planned:
+ print("[INFO] Nothing to convert (all books skipped)")
+ return 0
+
+ try:
+ converter = AudiobookConverter(
+ voice_mode=voice_mode, voice_clone_ref_audio=clone,
+ voice_clone_ref_text=transcription,
+ skip_transcription=no_transcription, speed=speed,
+ single_file=single_file, output_format=output_format,
+ language=language, backend=backend, voice=voice, debug=debug,
+ chunk=chunk, model_id=model_id, instructions=instructions,
+ request_options=request_options,
+ )
+ converter._book_files = book_files
+ converter._planned = planned
+ ok = converter.run()
+ except KeyboardInterrupt:
+ print("\n[WARNING] Shutdown requested by user")
+ return 130
+ except Exception as exc:
+ print(f"[FATAL] Fatal error: {exc}")
+ traceback.print_exc()
+ return 1
+ return 0 if ok else 1
+
+
def main() -> None:
- """Entry point with argparse."""
+ """Entry point: TUI hub with no args in a terminal, else argparse CLI."""
+ # No arguments + interactive terminal -> the TUI hub (set up backends
+ # and process the input directory end-to-end). Anything else is the
+ # scriptable argparse CLI.
+ if not sys.argv[1:]:
+ try:
+ interactive = sys.stdin.isatty() and sys.stdout.isatty()
+ except (AttributeError, ValueError):
+ interactive = False
+ if interactive:
+ import hub
+ sys.exit(hub.run())
+ # Non-interactive with no args: a default conversion run (cron/etc).
+ sys.exit(convert())
+
parser = argparse.ArgumentParser(
description="Convert books to audiobooks using a local TTS server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
- # Use the default audio.cpp audiocpp_server (speaker mode - Vivian speaker, or a server-side voice)
+ # No arguments, in a terminal: the full TUI (set up backends, convert).
python audiobook.py
- # Use the audio.cpp audiocpp_server with a server-side voice preset
+ # Use the audio.cpp audiocpp_server (speaker mode - Vivian speaker, or a server-side voice)
python audiobook.py --backend audiocpp --voice narrator
# Use the audio.cpp audiocpp_server with a voice design model (task 'vdes')
@@ -64,34 +141,23 @@ Examples:
)
parser.add_argument(
- "--clone",
- type=str,
- metavar="PATH",
+ "--clone", type=str, metavar="PATH",
help=("Path to reference audio file for voice cloning (WAV format). "
"Passing this flag switches the converter to voice clone mode.")
)
-
parser.add_argument(
- "--transcription",
- type=str,
- default=None,
+ "--transcription", type=str, default=None,
help=("Transcript of the reference audio for in-context cloning (recommended for "
"highest quality). If omitted, a local Whisper backend is used if installed; "
"otherwise the converter falls back to x-vector-only mode.")
)
-
parser.add_argument(
- "--no-transcription",
- action="store_true",
+ "--no-transcription", action="store_true",
help=("Skip automatic transcription of the reference audio (use x-vector-only "
"cloning). Ignored when --transcription is provided.")
)
-
parser.add_argument(
- "--language",
- type=str,
- default=None,
- metavar="LANG",
+ "--language", type=str, default=None, metavar="LANG",
help=("Output language for the synthesized speech, e.g. English, Japanese, "
"or Auto (language names and short codes like en/ja are accepted). "
"With --backend audiocpp the language is adapted to the model "
@@ -99,32 +165,22 @@ Examples:
"omitted when the model detects the language itself. Defaults to "
"the LANGUAGE setting in converter/config.py (English).")
)
-
parser.add_argument(
- "--speed",
- type=float,
- default=1.0,
+ "--speed", type=float, default=1.0,
help="Playback speed factor for the final audiobook (1.0 = normal). Pitch-preserving."
)
-
parser.add_argument(
- "--format",
- choices=list(AUDIO_FORMATS),
- default=config.AUDIO_FORMAT,
+ "--format", choices=list(AUDIO_FORMATS), default=config.AUDIO_FORMAT,
help=f"Output container format (default: {config.AUDIO_FORMAT}). m4b uses AAC audio."
)
-
parser.add_argument(
- "--single-file",
- action="store_true",
+ "--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. "
"Ignored for m4b, which is always a single file.")
)
-
parser.add_argument(
- "--backend",
- choices=[BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER],
+ "--backend", choices=[BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER],
default=config.BACKEND,
help=("TTS server to talk to: the Qwen3-TTS demo server (qwen), the "
"faster-qwen3-tts OpenAI-compatible server (faster), or an "
@@ -133,12 +189,8 @@ Examples:
"and more. Defaults to the BACKEND setting in "
"converter/config.py (audiocpp).")
)
-
parser.add_argument(
- "--voice",
- type=str,
- default=None,
- metavar="NAME",
+ "--voice", type=str, default=None, metavar="NAME",
help=("Voice to request from a server-side voice configuration. faster: "
"a key in the server's voices.json ('default' when it was started "
"with --ref-audio). audiocpp: a voice_preset or voice_dir entry "
@@ -147,44 +199,32 @@ Examples:
"the qwen backend (use converter/config.py SPEAKER or --clone "
"there).")
)
-
parser.add_argument(
- "--debug",
- action="store_true",
+ "--debug", action="store_true",
help=("Troubleshooting mode: dump each chunk's raw audio and the exact text "
"sent for it under the debug/ folder (organized per book and chapter), "
"and log every TTS request and response to the console and log file.")
)
-
parser.add_argument(
- "--chunk",
- action="store_true",
+ "--chunk", action="store_true",
help=("Force client-side chunking into CHUNK_SIZE-word requests (see "
"converter/config.py). Only matters for --backend audiocpp, which "
"otherwise sends each chapter as one request and lets the server "
"chunk long text itself; the qwen and faster backends always "
"chunk.")
)
-
parser.add_argument(
- "--model",
- type=str,
- default=None,
- metavar="ID",
+ "--model", type=str, default=None, metavar="ID",
help=("audio.cpp server model entry id to use for this run "
"(--backend audiocpp only). Overrides AUDIOCPP_MODEL_ID in "
"converter/config.py, which is useful for a server hosting "
"several lazily-loaded models: generate one server.json with "
- "tools/make_audiocpp_server_json.py, then pick the model per "
- "run with --model. Leave unset to use the config id, or to "
- "auto-select when the server hosts exactly one entry.")
+ "backends.audiocpp, then pick the model per run with --model. "
+ "Leave unset to use the config id, or to auto-select when the "
+ "server hosts exactly one entry.")
)
-
parser.add_argument(
- "--instructions",
- type=str,
- default=None,
- metavar="TEXT",
+ "--instructions", type=str, default=None, metavar="TEXT",
help=("Voice design or style instruction sent with every request "
"(--backend audiocpp only). Required for voice design models "
"(server entries with task 'vdes', e.g. Qwen3-TTS "
@@ -194,13 +234,8 @@ Examples:
"the model supports one and is ignored otherwise. Defaults to "
"AUDIOCPP_INSTRUCTIONS in converter/config.py (empty).")
)
-
parser.add_argument(
- "--option",
- action="append",
- type=str,
- default=None,
- metavar="KEY=VALUE",
+ "--option", action="append", type=str, default=None, metavar="KEY=VALUE",
help=("Request option passed through to the audio.cpp model "
"(--backend audiocpp only); repeatable. Whatever the hosted "
"family supports (emotion, voice_id, speed, speaking_rate, "
@@ -290,68 +325,16 @@ Examples:
parser.error(f"--option expects KEY=VALUE (got {item!r})")
request_options[key.strip()] = value
- setup_logging(debug=args.debug)
- setup_directories()
-
- if args.backend == BACKEND_FASTER:
- voice_mode = VOICE_MODE_CLONE
- elif args.backend == BACKEND_AUDIOCPP:
- voice_mode = VOICE_MODE_CLONE if args.voice else VOICE_MODE_CUSTOM
- else:
- voice_mode = VOICE_MODE_CLONE if args.clone else VOICE_MODE_CUSTOM
-
- # Ask every overwrite question up front, before spending time connecting
- # to a TTS server: a user who declines (or has nothing to convert) never
- # waits on a slow server handshake. Nothing in this step needs the server.
- book_files, planned = AudiobookConverter.preflight_overwrites(
- backend=args.backend,
- voice=args.voice,
- voice_mode=voice_mode,
- voice_clone_ref_audio=args.clone,
- output_format=args.format,
- instructions=args.instructions,
- )
-
- if not book_files:
- print("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file "
- "to the input folder and run again.")
- sys.exit(0)
-
- if not planned:
- print("[INFO] Nothing to convert (all books skipped)")
- sys.exit(0)
-
- try:
- converter = AudiobookConverter(
- voice_mode=voice_mode,
- voice_clone_ref_audio=args.clone,
- voice_clone_ref_text=args.transcription,
- skip_transcription=args.no_transcription,
- speed=args.speed,
- single_file=args.single_file,
- output_format=args.format,
- language=args.language,
- backend=args.backend,
- voice=args.voice,
- debug=args.debug,
- chunk=args.chunk,
- model_id=args.model,
- instructions=args.instructions,
- request_options=request_options,
- )
- converter._book_files = book_files
- converter._planned = planned
- ok = converter.run()
- except KeyboardInterrupt:
- print("\n[WARNING] Shutdown requested by user")
- sys.exit(130)
- except Exception as exc:
- print(f"[FATAL] Fatal error: {exc}")
- traceback.print_exc()
- sys.exit(1)
-
- sys.exit(0 if ok else 1)
+ sys.exit(convert(
+ 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, chunk=args.chunk,
+ model_id=args.model, instructions=args.instructions,
+ request_options=request_options,
+ ))
if __name__ == "__main__":
main()
+