#!/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 from typing import Optional # 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, voice_mode_for, ) 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 run_log_path() -> Path: """The full path of this run's dated log file (app/logs/audiobook_*.log). Pointed at from the console when a conversion run fails: the file keeps the full record (the failure's traceback, per-chunk errors) that the console summary only sketches. """ return logging_kit.stream_path("audiobook", _converter_mod.LOGS_FOLDER) def _all_models_emit(progress, model_id: str, book_offset: int, grand_total: int, counts: dict): """Wrap one model's converter progress for an "All" run. Book events are renumbered into the run's global book sequence (BOOK_OFFSET plus the model's own index, GRAND_TOTAL overall) and stamped with the generating model; book_done/book_failed carry the model too. Every other converter event (chapter, chunks, chunk_done, chunk_failed) is stamped with the model as well, so the run view can attribute e.g. a chunk failure to the model that produced it. The per-model "done"/"cancelled" events are swallowed — the loop emits one merged "done" when every model has run — and book_done outcomes are counted into COUNTS for that merged event. """ def emit(event: dict) -> None: kind = event.get("kind") if kind == "book": progress({**event, "index": book_offset + (event.get("index") or 0), "total": grand_total, "model": model_id}) elif kind in ("book_done", "book_failed"): if kind == "book_done" and event.get("ok"): counts["ok"] += 1 progress({**event, "model": model_id}) elif kind in ("done", "cancelled"): return else: progress({**event, "model": model_id}) return emit def _convert_each_model(*, backend: str, model_ids: list, model_voices: dict, planned_by_model, book_files, confirm, progress, cancel, clone, transcription, no_transcription: bool, language, speed: float, single_file: bool, output_format: str, debug: bool, instructions: Optional[str], request_options: dict, api_url: Optional[str]) -> int: """Run one conversion per model (the Generate form's "All" pick). Model-major: every planned book is converted with model 1, then model 2, ... — one AudiobookConverter per model, each unloading previously loaded server models at connect (audio.cpp: clean VRAM between models). The per-model voice comes from MODEL_VOICES; output names carry the model tag (planned per model by the hub's pre-flight, or computed here when PLANNED_BY_MODEL is absent). A failed book aborts only that model's remaining books (the converter's own rule) and a model that cannot even start (connect-time validation, unreachable server) is reported and skipped; the loop continues with the next model. A cancel event or KeyboardInterrupt stops everything. PROGRESS events are renumbered into one global book sequence stamped with the generating model (see _all_models_emit), and one merged "done" event is emitted at the end. Returns the exit code (0 on success, 130 on Ctrl-C). """ model_voices = dict(model_voices or {}) if planned_by_model is None: # No plans from the caller (the hub pre-flights every model inside # the TUI so the overwrite prompts are asked there): plan here. planned_by_model = {} for model_id in model_ids: voice = model_voices.get(model_id) _, planned = AudiobookConverter.preflight_overwrites( backend=backend, voice=voice, voice_mode=voice_mode_for(backend, voice, clone, instructions), voice_clone_ref_audio=clone, output_format=output_format, instructions=instructions, confirm=confirm, book_files=book_files, name_tag=AudiobookConverter.compute_model_tag(model_id)) planned_by_model[model_id] = planned planned_by_model = {model_id: (planned_by_model.get(model_id) or []) for model_id in model_ids} if not book_files and not any(planned_by_model.values()): print("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file " "to the input folder and run again.") return 0 if not any(planned_by_model.values()): print("[INFO] Nothing to convert (all books skipped)") return 0 grand_total = sum(len(entries) for entries in planned_by_model.values()) successful = 0 cancelled = False book_offset = 0 for model_id in model_ids: planned = planned_by_model[model_id] if not planned: continue if cancel is not None and cancel.is_set(): cancelled = True break voice = model_voices.get(model_id) counts = {"ok": 0} emit = (_all_models_emit(progress, model_id, book_offset, grand_total, counts) if progress is not None else None) ok = False model_ok = 0 try: converter = AudiobookConverter( voice_mode=voice_mode_for(backend, voice, clone, instructions), 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, # "All" runs always start each model with a clean VRAM. unload_models=True, progress=emit, cancel=cancel, ) converter._book_files = book_files converter._planned = planned ok = converter.run() model_ok = counts["ok"] if progress is not None \ else (len(planned) if ok else 0) except KeyboardInterrupt: print("\n[WARNING] Shutdown requested by user") return 130 except Exception as exc: # A model that cannot even start (connect-time validation, an # unreachable server) must not sink the remaining models: the # run view shows it as a failed result line and the loop # continues with the next model. logging_kit.log_traceback() if progress is not None: progress({"kind": "book_failed", "name": model_id, "error": str(exc), "files": []}) else: print(f"[FATAL] {model_id}: {exc}") successful += model_ok book_offset += len(planned) if cancel is not None and cancel.is_set(): cancelled = True break ok = not cancelled and grand_total > 0 and successful == grand_total if progress is not None: progress({"kind": "done", "ok": successful, "total": grand_total, "cancelled": cancelled}) if not ok and progress is None: print(f"[INFO] Full details in the log file: {run_log_path()}") return 0 if ok else 1 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, model_ids=None, model_voices=None, planned_by_model=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 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. MODEL_IDS switches to the "All (multiple generation)" mode (the TUI's Generate form "All" model pick): one conversion per model, model-major (every book with model 1, then model 2, ...), each with its own voice from MODEL_VOICES ({model_id: voice-or-None}; the picked voice is used where a model accepts it, its fallback where it does not) and its own model-tagged output names. PLANNED_BY_MODEL ({model_id: [(book, name), ...]}) carries a per-model pre-flight result so the overwrite prompts are not asked again; without it the plans are computed here (asking CONFIRM). Every per-model conversion unloads previously-loaded server models first (audio.cpp: clean VRAM between models), a failed book aborts only that model's remaining books, and the loop continues with the next model; cancellation stops everything. The run view's book events are renumbered into one global sequence and stamped with the generating model, and one merged "done" event is emitted at the end. """ 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 model_ids: # "All (multiple generation)": one conversion per model, with the # picked voice applied per model and model-tagged output names. return _convert_each_model( backend=backend, model_ids=[str(m) for m in model_ids], model_voices=model_voices or {}, planned_by_model=planned_by_model, book_files=book_files, confirm=confirm, progress=progress, cancel=cancel, clone=clone, transcription=transcription, no_transcription=no_transcription, language=language, speed=speed, single_file=single_file, output_format=output_format, debug=debug, instructions=instructions, request_options=request_options, api_url=api_url) 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 progress is None: # The run view owns the TUI console and points failures at the # log itself (the error screen's details hint). print(f"[INFO] Full details in the log file: {run_log_path()}") 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() if not ok and progress is None: # The run failed (a book aborted the rest): the summary above only # names the failed books, so point at the log with the details. print(f"[INFO] Full details in the log file: {run_log_path()}") 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()