#!/usr/bin/env python3 """ 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. """ import argparse import sys import traceback # Fix Windows console encoding for unicode output if sys.platform == "win32": try: sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") except AttributeError: pass # The managed-environment bootstrap (backends.envs) is stdlib-only and is # imported here so main() can launch it before any third-party dependency is # touched. It must NOT run at import time (importing this module must stay # light so the TUI hub and the tests can import it from any environment); it # runs only when audiobook.py is executed as a script, from main() below. from backends import envs as _envs # noqa: I001 from converter import config from converter.converter import ( AUDIO_FORMATS, AudiobookConverter, setup_directories, setup_logging, ) from converter.tts import ( BACKEND_AUDIOCPP, BACKEND_FASTER, BACKEND_QWEN, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, normalize_language, ) 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: TUI hub with no args in a terminal, else argparse CLI.""" # Run inside the managed venv (envs/tts), creating it (and installing # requirements.txt) first if needed. A no-op when already there. Done # here rather than at import time so importing this module is light. _envs.bootstrap(__file__) # 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: from ui 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: # No arguments, in a terminal: the full TUI (set up backends, convert). python audiobook.py # 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') python audiobook.py --backend audiocpp --model qwen-design \\ --instructions "A warm adult female narrator with a British accent" # Use the qwen-tts demo server with a custom voice python audiobook.py --backend qwen # Use the qwen-tts demo server with voice cloning from reference audio python audiobook.py --backend qwen --clone path/to/reference.wav # Use the faster-qwen3-tts server (voice cloning, configured server-side) python audiobook.py --backend faster [--voice NAME] """ ) parser.add_argument( "--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, 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", 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", 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 " "family: sent as a code (e.g. 'en') for families that take one, or " "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, 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, help=f"Output container format (default: {config.AUDIO_FORMAT}). m4b uses AAC audio." ) 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. " "Ignored for m4b, which is always a single file.") ) parser.add_argument( "--backend", choices=[BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER], default=config.BACKEND, help=("TTS server to talk to: the qwen-tts demo server (qwen), the " "faster-qwen3-tts OpenAI-compatible server (faster), or an " "audio.cpp audiocpp_server (audiocpp) hosting any of its TTS " "model families — Qwen3-TTS, Higgs Audio, VoxCPM2, IndexTTS2, " "and more. Defaults to the BACKEND setting in " "converter/config.py (audiocpp).") ) parser.add_argument( "--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 " "(cloning); required for audio.cpp families without built-in " "speakers (everything except Qwen3-TTS CustomVoice). Not used by " "the qwen backend (use converter/config.py SPEAKER or --clone " "there).") ) parser.add_argument( "--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", 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", 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 " "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", 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 " "VoiceDesign): describe the voice to synthesize with, e.g. " "'A warm adult female narrator with a British accent'. On " "other families it acts as a style/delivery instruction when " "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", 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, " "temperature, ...) — unsupported keys are ignored by the " "model. See the audio.cpp docs for the model's valid option " "keys, e.g. --option emotion=neutral --option speed=1.1.") ) args = parser.parse_args() if args.speed <= 0: parser.error(f"--speed must be a positive number (got {args.speed:g})") if args.chunk: if args.backend == BACKEND_AUDIOCPP: print("[WARNING] --chunk: the audio.cpp server already splits long text " "internally (its text_chunk_size); forcing client-side chunking " "may cause needless double-chunking") else: print(f"[INFO] --chunk has no effect with --backend {args.backend}: " "that backend always chunks") if args.backend == BACKEND_FASTER: if args.clone: print("[WARNING] --clone is ignored with --backend faster: that backend " "always uses voice cloning, and the reference voice is configured " "on the server (see README)") args.clone = None if args.transcription or args.no_transcription: print("[WARNING] --transcription/--no-transcription are ignored with " "--backend faster: the reference transcript is configured on the " "server (--ref-text or voices.json, see README)") args.transcription = None args.no_transcription = False if args.language is not None: print("[WARNING] --language is ignored with --backend faster: language " "is configured on the server (see README)") args.language = None elif args.backend == BACKEND_AUDIOCPP: if args.clone: print("[WARNING] --clone is ignored with --backend audiocpp: cloning " "uses a voice configured on the server (voice_presets or " "voice_dir in its config); select it with --voice (see README)") args.clone = None if args.transcription or args.no_transcription: print("[WARNING] --transcription/--no-transcription are ignored with " "--backend audiocpp: the reference transcript is configured on " "the server (see README)") args.transcription = None args.no_transcription = False if args.language is not None: try: args.language = normalize_language(args.language) except ValueError as exc: parser.error(str(exc)) else: if args.voice is not None: parser.error("--voice requires --backend faster or audiocpp; the " "qwen backend uses built-in speakers " "(converter/config.py SPEAKER) or --clone") if args.language is not None: try: args.language = normalize_language(args.language) except ValueError as exc: parser.error(str(exc)) if not args.clone and (args.transcription or args.no_transcription): print("[WARNING] --transcription/--no-transcription " "are ignored without --clone") if args.model is not None and args.backend != BACKEND_AUDIOCPP: parser.error("--model requires --backend audiocpp; it selects an " "audio.cpp server model entry id") if args.instructions is not None and args.backend != BACKEND_AUDIOCPP: parser.error("--instructions requires --backend audiocpp; it is " "sent as the audio.cpp request's instructions field") request_options = {} if args.option: if args.backend != BACKEND_AUDIOCPP: parser.error("--option requires --backend audiocpp; the options " "are passed through to the audio.cpp model") for item in args.option: key, sep, value = item.partition("=") if not sep or not key.strip(): parser.error(f"--option expects KEY=VALUE (got {item!r})") request_options[key.strip()] = value 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()