aboutsummaryrefslogtreecommitdiff
path: root/converter
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-20 22:38:58 +0000
committerhistoria <historiavg@proton.me>2026-08-20 23:02:09 +0000
commite4b42be01fc031810160126013833175413ec84c (patch)
tree1793b8d060d1272c9f95290599e45e2f1b477ff3 /converter
parent5ca77f86b70718b4ef1a07299efbd6431268d546 (diff)
downloadtts-audiobook-generator-e4b42be01fc031810160126013833175413ec84c.tar.gz
feat: prompt for overwrites before connecting to TTS server
Diffstat (limited to 'converter')
-rw-r--r--converter/converter.py113
1 files changed, 81 insertions, 32 deletions
diff --git a/converter/converter.py b/converter/converter.py
index f1064f1..84ac766 100644
--- a/converter/converter.py
+++ b/converter/converter.py
@@ -106,8 +106,9 @@ def prompt_overwrite(existing: List[Path], output_name: str) -> bool:
"""Ask whether to reconvert a book whose output files already exist.
All overwrite questions are asked before any conversion starts so the
- rest of the run is unattended. Returns False when no interactive input
- is available (stdin closed), keeping existing files safe.
+ rest of the run is unattended. Pressing Enter defaults to yes (so a
+ user can just hit Enter through the prompts), but a closed stdin
+ (non-interactive run) declines and keeps existing files safe.
"""
if len(existing) == 1:
message = f"{existing[0].name} already exists. Convert anyway and overwrite it?"
@@ -116,15 +117,17 @@ def prompt_overwrite(existing: List[Path], output_name: str) -> bool:
f"(e.g. {existing[0].name}). Convert anyway and overwrite them?")
while True:
try:
- answer = input(f"{message} (y/n): ").strip().lower()
+ answer = input(f"{message} [Y/n]: ").strip().lower()
except EOFError:
print("\n[WARNING] No interactive input available; keeping existing output")
return False
+ if not answer:
+ return True
if answer in ("y", "yes"):
return True
if answer in ("n", "no"):
return False
- print("Please answer 'y' or 'n'.")
+ print("Please answer 'y' or 'n' (or press Enter for yes).")
class AudiobookConverter:
@@ -207,23 +210,36 @@ class AudiobookConverter:
return cleaned[:80] or fallback
def _narrator_tag(self) -> str:
- """Narrator name used in output file names.
+ """Narrator name used in output file names (see compute_narrator_tag)."""
+ return self.compute_narrator_tag(
+ self.backend, self.voice, self.voice_mode, self.voice_clone_ref_audio)
+
+ @staticmethod
+ def compute_narrator_tag(backend: str, voice: Optional[str],
+ voice_mode: str,
+ voice_clone_ref_audio: Optional[str]) -> str:
+ """Narrator name used in output file names, without a server connection.
Custom voice mode uses the built-in speaker's display name; voice
clone mode uses the reference audio file's stem; the faster and
audiocpp backends use the server-side voice name (falling back to
the built-in speaker for the audiocpp backend's speaker mode).
Spaces become underscores (e.g. "Uncle Fu" -> "Uncle_Fu").
+
+ Pure (no I/O, no server) so the pre-flight overwrite check can
+ compute the exact output names a run would produce before spending
+ time connecting to a TTS server.
"""
- if self.backend == BACKEND_FASTER:
- narrator = self.voice or config.FASTER_VOICE
- elif self.backend == BACKEND_AUDIOCPP:
- narrator = self.voice or speaker_display_name()
- elif self.voice_mode == VOICE_MODE_CLONE:
- narrator = Path(self.voice_clone_ref_audio).stem
+ if backend == BACKEND_FASTER:
+ narrator = voice or config.FASTER_VOICE
+ elif backend == BACKEND_AUDIOCPP:
+ narrator = voice or speaker_display_name()
+ elif voice_mode == VOICE_MODE_CLONE:
+ narrator = Path(voice_clone_ref_audio).stem
else:
narrator = speaker_display_name()
- return self._sanitize_filename(narrator, fallback="narrator").replace(" ", "_")
+ return AudiobookConverter._sanitize_filename(
+ narrator, fallback="narrator").replace(" ", "_")
# ------------------------------------------------------------------
# Debug dumps (--debug)
@@ -580,30 +596,33 @@ class AudiobookConverter:
print(f"Debug dumps (per-chunk text + raw audio): {DEBUG_FOLDER}")
print("=" * 70)
- def run(self) -> bool:
- """Main conversion process. Returns True if all books converted."""
- run_start = time.time()
- self._print_banner()
+ # ------------------------------------------------------------------
+ # Pre-flight: overwrite checks before connecting to a TTS server
+ # ------------------------------------------------------------------
+ @staticmethod
+ def preflight_overwrites(backend: str, voice: Optional[str],
+ voice_mode: str,
+ voice_clone_ref_audio: Optional[str],
+ output_format: str) -> Tuple[List[Path],
+ List[Tuple[Path, str]]]:
+ """Discover books and ask every overwrite question up front.
+
+ Pure of the TTS server: it scans the books folder, computes the
+ output name each book would produce (including the narrator tag
+ and stem-collision suffix), and asks whether to overwrite any
+ existing output files. Returns ``(book_files, planned)`` where
+ ``planned`` is the subset the user agreed to (re)convert.
+
+ Asking before connecting means a user who declines a prompt (or
+ has nothing to convert) never waits on a slow server handshake.
+ """
book_files = sorted(
f for f in BOOKS_FOLDER.iterdir()
if f.is_file() and f.suffix.lower() in SUPPORTED_FORMATS
)
-
if not book_files:
- print(f"[INFO] No supported files found in {BOOKS_FOLDER}")
- print(f"Supported formats: {', '.join(SUPPORTED_FORMATS)}")
-
- # Create sample file
- sample_file = BOOKS_FOLDER / "sample.txt"
- sample_file.write_text(
- "This is a sample audiobook for testing the Qwen-based converter. "
- "The system will send this text to the Qwen API for voice generation. "
- "You can replace this file with your own books to convert.",
- encoding="utf-8",
- )
- print(f"[INFO] Created sample file: {sample_file}")
- return True
+ return [], []
print(f"[INFO] Found {len(book_files)} books to convert")
@@ -613,16 +632,46 @@ class AudiobookConverter:
# Ask every overwrite question up front, before any conversion
# starts, so the rest of the run is unattended.
planned: List[Tuple[Path, str]] = []
+ narrator_tag = AudiobookConverter.compute_narrator_tag(
+ backend, voice, voice_mode, voice_clone_ref_audio)
for book_file in book_files:
output_name = book_file.stem
if stem_counts[book_file.stem] > 1:
output_name = f"{book_file.stem}_{book_file.suffix.lstrip('.')}"
- output_name = f"{output_name}_{self._narrator_tag()}"
- existing = find_existing_outputs(output_name, self.output_format)
+ output_name = f"{output_name}_{narrator_tag}"
+ existing = find_existing_outputs(output_name, output_format)
if existing and not prompt_overwrite(existing, output_name):
print(f"[INFO] Skipping {book_file.name} (existing output kept)")
continue
planned.append((book_file, output_name))
+ return book_files, planned
+
+ # ------------------------------------------------------------------
+ # Main conversion loop
+ # ------------------------------------------------------------------
+
+ def run(self) -> bool:
+ """Main conversion process. Returns True if all books converted."""
+ run_start = time.time()
+ self._print_banner()
+
+ # When main() has already done the pre-flight overwrite check, use
+ # its results so the prompts are not asked a second time; otherwise
+ # (e.g. a converter constructed directly) discover and ask here.
+ if getattr(self, "_planned", None) is not None:
+ book_files = self._book_files
+ planned = self._planned
+ else:
+ book_files, planned = AudiobookConverter.preflight_overwrites(
+ self.backend, self.voice, self.voice_mode,
+ self.voice_clone_ref_audio, self.output_format)
+
+ if not book_files:
+ print(f"[INFO] No supported files found in {BOOKS_FOLDER}")
+ print(f"Supported formats: {', '.join(SUPPORTED_FORMATS)}")
+ print("[INFO] Nothing to convert. Add a .txt, .pdf, or .epub file "
+ f"to {BOOKS_FOLDER} and run again.")
+ return True
if not planned:
print("[INFO] Nothing to convert (all books skipped)")