#!/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. --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 for server URLs and processing settings; the TTS backend, model and voice are chosen per run with CLI flags. Without --api-url the CLI manages the server itself: it starts the backend's managed instance (the one installed via the TUI), converts, and stops it again — unless a server is already running at the configured endpoint, which is used as-is and left running. --api-url targets an externally-run server and never touches server state. """ import argparse import sys import traceback from pathlib import Path # 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 # Everything non-user-facing (source packages, generated dirs, venv, backend # checkouts) lives under ./app so the checkout root stays clean. Put it on # sys.path before importing the packages below. APP_DIR = Path(__file__).resolve().parent / "app" sys.path.insert(0, str(APP_DIR)) # 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 # app/logs owner: naming conventions and startup pruning (stdlib-only). import logging_kit # noqa: I001 from converter import config from converter import converter as _converter_mod from converter.clients import ( BACKEND_AUDIOCPP, BACKEND_FASTER, BACKEND_QWEN, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, VOICE_MODE_DESIGN, is_builtin_speaker, normalize_language, ) 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, voice: str = None, clone: str = None, transcription: str = None, no_transcription: bool = False, language: str = None, speed: float = None, single_file: bool = False, output_format: str = None, debug: bool = 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, manage_server: bool = False) -> 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 is required (the CLI flag or the hub's Generate form supplies it); OUTPUT_FORMAT defaults to config.AUDIO_FORMAT. LANGUAGE is already-normalized where required. INPUT_DIR/OUTPUT_DIR override the configured INPUT_DIR/OUTPUT_DIR folders when given (relative paths resolve against the project root); SPEED/DEBUG default to the config.SPEED/config.DEBUG settings. API_URL, when given, overrides the configured server URL for the selected backend (used by the hub's "[remote]" entries and --api-url). MANAGE_SERVER (the CLI, without --api-url) boots the backend's managed server before converting — backends.managed.ensure_running — and stops it again afterwards, but only when this run started it; a server already answering at the configured endpoint is used as-is and left running. It is skipped when API_URL is set (an external server is never touched) and when nothing needs converting (the early returns below run first, so an empty input folder never boots a server). The hub leaves it False: its run view boots and stops the server itself. 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 overwrite prompts) and BOOK_FILES/PLANNED (a pre-flight result, so the overwrite prompts are not asked again) wire the conversion into the TUI run view; without them everything behaves like the CLI. """ if backend is None: raise ValueError("backend is required (pass --backend)") output_format = output_format or config.AUDIO_FORMAT speed = config.SPEED if speed is None else speed debug = config.DEBUG if debug is None else debug 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( output_dir, "output") # With a progress callback the run view owns the screen: keep log # records in the file only and let the events carry the state. setup_logging(debug=debug, console=progress is None) 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: # qwen: instructions design the voice (VoiceDesign model), a # reference .wav clones one (Base), otherwise a built-in speaker. if (instructions or "").strip(): voice_mode = VOICE_MODE_DESIGN else: voice_mode = VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM if book_files is None or planned is None: 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, 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 " "to the input folder and run again.") return 0 if not planned: print("[INFO] Nothing to convert (all books skipped)") return 0 # The CLI's server lifecycle (MANAGE_SERVER without API_URL): boot the # managed instance before converting and shut it down when the run is # over — success, failure, or Ctrl-C — via the finally below. A server # already running is reused and left running (shutdown is a no-op for # it), and a refused/failed boot (server.ok False) stops here. server = None try: if manage_server and api_url is None: from backends import managed server = managed.ensure_running(backend, voice_mode) if server is not None and not server.ok: return 1 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, model_id=model_id, instructions=instructions, request_options=request_options, api_url=api_url, progress=progress, cancel=cancel, ) 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: # Expected failures (bad flags, unreachable server, missing voice) # raise RuntimeError/ValueError with an actionable message: show # that once and keep the full traceback in the dated log file. # Anything else is an actual crash, so also show the traceback. logging_kit.log_traceback() print(f"[FATAL] Fatal error: {exc}") if not isinstance(exc, (RuntimeError, ValueError)): traceback.print_exc() if progress is not None: progress({"kind": "error", "message": str(exc)}) return 1 finally: if server is not None: server.shutdown() 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__) # Trim stale stream/artifact logs once per app start (server logs are # never touched); see app/logging_kit.py. logging_kit.prune_logs() # 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: there is no default backend/model/ # voice anymore, so a scripted run must say what it wants. print("[ERROR] No --backend given. Scripted (non-interactive) runs " "must pass --backend plus the backend's voice/model flags " "(see --help); run with no arguments in a terminal for the " "TUI.") sys.exit(2) 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 # Without --api-url the CLI starts the backend's managed server (installed # via the TUI), converts, and stops it again; a server already running at # the configured endpoint is used as-is and left running. # Use the audio.cpp audiocpp_server (a built-in speaker, or a server-side voice) python audiobook.py --backend audiocpp --voice Vivian # Use a server-side voice preset (cloning) on the audio.cpp server 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 Qwen3-TTS-12Hz-1.7B-VoiceDesign-GGUF \\ --instructions "A warm adult female narrator with a British accent" # Use the qwen-tts demo server with a built-in speaker python audiobook.py --backend qwen --voice Vivian # Use the qwen-tts demo server with voice cloning from reference audio python audiobook.py --backend qwen --clone path/to/reference.wav # Use the qwen-tts demo server with a designed voice (VoiceDesign model) python audiobook.py --backend qwen \\ --instructions "A warm adult female narrator with a British accent" # 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 """ ) 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 app/converter/config.py (English).") ) parser.add_argument( "--speed", type=float, default=None, help=("Playback speed factor for the final audiobook (1.0 = normal). " "Pitch-preserving. Defaults to the SPEED setting in " "app/converter/config.py.") ) parser.add_argument( "--format", choices=list(AUDIO_FORMATS), default=None, help=f"Output container format (default: {config.AUDIO_FORMAT}). m4b uses AAC audio." ) parser.add_argument( "--input", type=Path, metavar="DIR", default=None, help=("Directory containing the source books (.txt/.pdf/.epub). " "Defaults to the INPUT_DIR setting in " "app/converter/config.py (./input); relative paths resolve " "against the project root.") ) parser.add_argument( "--output", type=Path, metavar="DIR", default=None, help=("Directory to write finished audiobooks to. Defaults to the " "OUTPUT_DIR setting in app/converter/config.py (./output); " "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. " "Ignored for m4b, which is always a single file.") ) parser.add_argument( "--backend", choices=[BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER], required=True, 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.") ) parser.add_argument( "--voice", type=str, default=None, metavar="NAME", help=("Voice to request. faster: (required) a key in the server's " "voices.json. audiocpp: the name of the voice for the " "selected model entry — for the Qwen3-TTS CustomVoice entry " "a built-in speaker (e.g. Vivian, Ryan, Uncle Fu; required " "there), for every other family a voice_preset or voice_dir " "entry (cloning). qwen: a built-in CustomVoice speaker name " "(required for built-in-speaker runs; use --clone or " "--instructions for the other voice modes).") ) 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. " "Forces the DEBUG setting in app/converter/config.py on for this run.") ) 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). Required when the server hosts " "several lazily-loaded models: generate one server.json with " "backends.audiocpp, then pick the model per run with --model. " "Leave unset 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 or qwen). With audiocpp it is required " "for voice design models (server entries with task 'vdes', " "e.g. Qwen3-TTS VoiceDesign) and optional style/delivery " "control elsewhere; with qwen it selects the VoiceDesign " "model and describes the voice to synthesize with, e.g. " "'A warm adult female narrator with a British accent'.") ) 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.") ) parser.add_argument( "--api-url", type=str, default=None, metavar="URL", help=("URL of the TTS server to talk to, overriding the configured " "endpoint for the selected backend. Accept a host:port " "(e.g. 10.20.30.40:8080) or a full http(s):// URL. With " "--backend qwen it overrides the endpoint for the active " "voice mode (custom or clone). Without it the CLI starts the " "backend's managed server itself (when installed), converts, " "and stops it again — unless a server is already running at " "the configured endpoint, which is used as-is and left " "running; an explicit --api-url never touches server state.") ) args = parser.parse_args() # An explicit --speed overrides the config SPEED setting; either way # the value must be a positive number. speed = args.speed if args.speed is not None else config.SPEED try: bad_speed = not isinstance(speed, (int, float)) or speed <= 0 except TypeError: bad_speed = True 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 " "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 if not args.voice: parser.error("--backend faster requires --voice: a key in the " "server's voices.json (see README)") 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: # qwen: --voice names a built-in CustomVoice speaker (the only # voice mode that needs one; --clone and --instructions pick the # Base/VoiceDesign models instead). if args.voice is not None and not is_builtin_speaker(args.voice): parser.error(f"--backend qwen: --voice {args.voice!r} is not a " "built-in speaker (want one of: Vivian, Serena, " "Uncle_Fu, Dylan, Eric, Ryan, Aiden, Ono_Anna, " "Sohee); use --clone or --instructions for the " "other voice modes") 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 not args.clone and not (args.instructions or "").strip() \ and not args.voice: parser.error("--backend qwen needs a voice: pass --voice SPEAKER " "for a built-in speaker, --clone PATH to clone a " "reference .wav, or --instructions \"...\" to " "design a voice") 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 not in \ (BACKEND_AUDIOCPP, BACKEND_QWEN): parser.error("--instructions requires --backend audiocpp or qwen; " "it is sent as the request's voice design/style 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") from backends.common import parse_request_options try: # Joined with spaces so both "--option a=1 --option b=2" and a # single quoted "a=1, b=2" parse identically (the shared parser # splits on commas or whitespace). request_options = parse_request_options(" ".join(args.option)) except ValueError as exc: parser.error(str(exc)) api_url = None if args.api_url is not None: from backends import common as _common try: api_url = _common.normalize_remote_url(args.api_url) except ValueError as exc: parser.error(str(exc)) if not api_url: parser.error("--api-url must not be empty") 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=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, manage_server=True, )) if __name__ == "__main__": main()