#!/usr/bin/env python3 """ Qwen-Based Audiobook Converter Converts TXT, PDF and EPUB files into audiobooks using a local Qwen3-TTS server. 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 from converter import config from converter.converter import ( AUDIO_FORMATS, AudiobookConverter, setup_directories, setup_logging, ) from converter.tts import ( BACKEND_AUDIOCPP, BACKEND_FASTER, BACKEND_GRADIO, VOICE_MODE_CLONE, VOICE_MODE_CUSTOM, normalize_language, ) def main() -> None: """Entry point with argparse.""" parser = argparse.ArgumentParser( description="Convert books to audiobooks using the Qwen3-TTS voice model", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Use the Qwen Gradio demo with a custom voice (default - Vivian speaker) python audiobook.py # Use the Qwen Gradio demo with voice cloning from reference audio python audiobook.py --clone path/to/reference.wav # Use the faster-qwen3-tts server (voice cloning, configured server-side) python audiobook.py --backend faster [--voice NAME] # Use an audio.cpp audiocpp_server (speaker mode, or a server-side voice) python audiobook.py --backend audiocpp [--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_GRADIO, BACKEND_FASTER, BACKEND_AUDIOCPP], default=config.BACKEND, help=("TTS server to talk to: the Qwen3-TTS Gradio demos (gradio), 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 (gradio).") ) 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 gradio 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 gradio 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 " "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.") ) 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 " "gradio 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") 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, ) 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, ) 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) if __name__ == "__main__": main()