aboutsummaryrefslogtreecommitdiff
path: root/audiobook.py
blob: 183bdc2df8f0d0c9a8b80d7827faea150a4c9e6f (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
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
#!/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()