diff options
| author | historia <historiavg@proton.me> | 2026-08-31 19:45:57 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-31 19:45:57 -0400 |
| commit | 10e72d4960e865acf5346ab8cf518ed5844fe45c (patch) | |
| tree | adf8c10386b9da6280c247f1fed137ef1a514157 /audiobook.py | |
| parent | 4bd0282da65db9f118ef5250582ab67079fad538 (diff) | |
| download | tts-audiobook-generator-10e72d4960e865acf5346ab8cf518ed5844fe45c.tar.gz | |
feat: generate a book with all installed models to compare
Diffstat (limited to 'audiobook.py')
| -rwxr-xr-x | audiobook.py | 182 |
1 files changed, 181 insertions, 1 deletions
diff --git a/audiobook.py b/audiobook.py index 64ec0d6..031ae1b 100755 --- a/audiobook.py +++ b/audiobook.py @@ -21,6 +21,7 @@ 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": @@ -64,6 +65,7 @@ from converter.converter import ( SUPPORTED_FORMATS, setup_directories, setup_logging, + voice_mode_for, ) from converter.converter import BASE_DIR as _BASE_DIR @@ -90,6 +92,151 @@ def run_log_path() -> Path: 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. 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) + 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, @@ -99,7 +246,9 @@ def convert(backend: str, voice: str = None, clone: str = 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) -> int: + 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 @@ -135,6 +284,21 @@ def convert(backend: str, voice: str = None, clone: str = None, 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)") @@ -169,6 +333,22 @@ def convert(backend: str, voice: str = None, clone: str = None, 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: |
