aboutsummaryrefslogtreecommitdiff
path: root/audiobook.py
diff options
context:
space:
mode:
Diffstat (limited to 'audiobook.py')
-rwxr-xr-xaudiobook.py182
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: