aboutsummaryrefslogtreecommitdiff
path: root/audiobook.py
blob: a984a0a8f9837cb592734ca0512f1c7539a4d819 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
#!/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.")
    )

    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")

    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,
        )
        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()