aboutsummaryrefslogtreecommitdiff
path: root/audiobook.py
blob: 1cc108507f3d73eed9062782d9e65f5c0e2a5cf0 (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
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
#!/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 app/converter/config.py to change voice and processing settings.
"""

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

from converter import config
from converter import converter as _converter_mod
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,
            model_id: str = None, instructions: str = None,
            request_options: dict = None, input_dir: Path = None,
            output_dir: Path = 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.
    INPUT_DIR/OUTPUT_DIR override the default input/ and output/ folders
    when given.
    """
    backend = backend or config.BACKEND
    output_format = output_format or config.AUDIO_FORMAT
    request_options = request_options or {}
    if input_dir is not None:
        _converter_mod.BOOKS_FOLDER = Path(input_dir)
    if output_dir is not None:
        _converter_mod.AUDIOBOOKS_FOLDER = Path(output_dir)
    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,
            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 app/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(
        "--input", type=Path, metavar="DIR", default=None,
        help=("Directory containing the source books (.txt/.pdf/.epub). "
              "Defaults to ./input.")
    )
    parser.add_argument(
        "--output", type=Path, metavar="DIR", default=None,
        help="Directory to write finished audiobooks to. Defaults to ./output."
    )
    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 "
              "app/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 app/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(
        "--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 "
              "app/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 app/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.input is not None and not args.input.is_dir():
        parser.error(f"--input: no such directory: {args.input}")

    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 "
                         "(app/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,
        model_id=args.model, instructions=args.instructions,
        request_options=request_options,
        input_dir=args.input, output_dir=args.output,
    ))


if __name__ == "__main__":
    main()