aboutsummaryrefslogtreecommitdiff
path: root/converter
diff options
context:
space:
mode:
Diffstat (limited to 'converter')
-rw-r--r--converter/config.py1
-rw-r--r--converter/converter.py108
2 files changed, 94 insertions, 15 deletions
diff --git a/converter/config.py b/converter/config.py
index dcec145..e5e5ff7 100644
--- a/converter/config.py
+++ b/converter/config.py
@@ -138,6 +138,7 @@ BOOKS_FOLDER = BASE_DIR / "input" # Input folder
AUDIOBOOKS_FOLDER = BASE_DIR / "output" # Output folder
CHUNKS_FOLDER = BASE_DIR / "chunks" # Scratch space for per-chunk audio (cleaned per book)
LOGS_FOLDER = BASE_DIR / "logs"
+DEBUG_FOLDER = BASE_DIR / "debug" # Per-chunk audio + text dumps for --debug (kept across runs)
# Words per TTS generation request. Each API call is ONE model generation:
# long generations lose prosody, can degrade into garbled audio, and text
diff --git a/converter/converter.py b/converter/converter.py
index 3b2782f..3e5d628 100644
--- a/converter/converter.py
+++ b/converter/converter.py
@@ -3,6 +3,7 @@
import glob
import logging
import re
+import shutil
import sys
import time
import traceback
@@ -18,7 +19,7 @@ from .tts import FasterTTSClient, QwenTTSClient, normalize_language, speaker_dis
logger = logging.getLogger(__name__)
-def setup_logging() -> None:
+def setup_logging(debug: bool = False) -> None:
"""Configure logging to both a dated file and the console."""
config.LOGS_FOLDER.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
@@ -32,6 +33,8 @@ def setup_logging() -> None:
logging.StreamHandler(sys.stdout),
],
)
+ if debug:
+ logging.getLogger("converter").setLevel(logging.DEBUG)
def setup_directories() -> None:
@@ -90,7 +93,7 @@ class AudiobookConverter:
voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False,
speed: float = 1.0, single_file: bool = False, output_format: str = config.AUDIO_FORMAT,
language: Optional[str] = None, faster: bool = False,
- faster_voice: Optional[str] = None):
+ faster_voice: Optional[str] = None, debug: bool = False):
if speed <= 0:
raise ValueError(f"Speed must be a positive number, got {speed}")
if output_format not in config.AUDIO_FORMATS:
@@ -106,6 +109,7 @@ class AudiobookConverter:
self.output_format = output_format
self.faster = faster
self.faster_voice = faster_voice
+ self.debug = bool(debug)
self._validate_configuration()
if faster:
# The faster backend always voice-clones using a reference voice
@@ -162,6 +166,50 @@ class AudiobookConverter:
narrator = speaker_display_name()
return self._sanitize_filename(narrator, fallback="narrator").replace(" ", "_")
+ # ------------------------------------------------------------------
+ # Debug dumps (--debug)
+ # ------------------------------------------------------------------
+
+ @staticmethod
+ def _write_debug_text(debug_dir: Path, chunk_num: int, text: str) -> None:
+ """Write the exact text sent for a chunk to the debug folder.
+
+ Called before the request so the text survives a crash mid-generation.
+ A failed debug write must never abort a conversion.
+ """
+ try:
+ debug_dir.mkdir(parents=True, exist_ok=True)
+ (debug_dir / f"chunk_{chunk_num:04d}.txt").write_text(text, encoding="utf-8")
+ except OSError as exc:
+ logger.warning("Could not write debug text for chunk %d: %s", chunk_num, exc)
+
+ @staticmethod
+ def _copy_debug_audio(debug_dir: Path, chunk_num: int, source: Path) -> Optional[Path]:
+ """Copy a generated chunk's audio file into the debug folder.
+
+ Returns the copy's path, or None when the copy failed (which never
+ affects the conversion itself).
+ """
+ try:
+ debug_dir.mkdir(parents=True, exist_ok=True)
+ target = debug_dir / f"chunk_{chunk_num:04d}{source.suffix or '.wav'}"
+ shutil.copy2(source, target)
+ return target
+ except OSError as exc:
+ logger.warning("Could not write debug audio for chunk %d: %s", chunk_num, exc)
+ return None
+
+ @staticmethod
+ def _chapter_debug_dir(book_debug_dir: Optional[Path], index: int, title: str) -> Optional[Path]:
+ """Per-chapter subfolder of a book's debug folder (None when not debugging).
+
+ Chunk numbering restarts for each chapter, so chapters get their own
+ subfolder (e.g. debug/dune_Vivian/03_The_Trial/).
+ """
+ if book_debug_dir is None:
+ return None
+ return book_debug_dir / f"{index:02d}_{AudiobookConverter._sanitize_filename(title)}"
+
def convert_book(self, file_path: Path, output_name: Optional[str] = None) -> bool:
"""Convert a single book to one or more audiobook files."""
logger.info("Converting: %s", file_path.name)
@@ -181,6 +229,9 @@ class AudiobookConverter:
stem = output_name or f"{file_path.stem}_{self._narrator_tag()}"
+ # --debug: chunk text/audio dumps land in a per-book folder
+ debug_dir = config.DEBUG_FOLDER / stem if self.debug else None
+
# Cover art: generated once per book. Named with the chunk_
# prefix so cleanup_chunks() removes it with the other scratch
# files at the end of the book.
@@ -195,16 +246,17 @@ class AudiobookConverter:
if self.output_format == "m4b":
if len(sections) > 1:
return self._convert_m4b_with_chapters(sections, stem, start_time,
- meta=meta, cover=cover_path)
+ meta=meta, cover=cover_path,
+ debug_dir=debug_dir)
output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
return self._convert_text(sections[0].text, output_path, start_time,
- meta=meta, cover=cover_path)
+ meta=meta, cover=cover_path, debug_dir=debug_dir)
if self.single_file or len(sections) == 1:
text = "\n\n".join(section.text for section in sections)
output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}"
return self._convert_text(text, output_path, start_time,
- meta=meta, cover=cover_path)
+ meta=meta, cover=cover_path, debug_dir=debug_dir)
success = True
for index, section in enumerate(sections, 1):
@@ -213,8 +265,11 @@ class AudiobookConverter:
track_meta = meta._replace(
title=(section.title or "").strip() or f"Chapter {index}",
track=index, total_tracks=len(sections))
- success = self._convert_text(section.text, output_path, time.time(),
- meta=track_meta, cover=cover_path) and success
+ success = self._convert_text(
+ section.text, output_path, time.time(),
+ meta=track_meta, cover=cover_path,
+ debug_dir=self._chapter_debug_dir(debug_dir, index, section.title)
+ ) and success
return success
except Exception as exc:
@@ -227,12 +282,15 @@ class AudiobookConverter:
def _convert_m4b_with_chapters(self, sections, stem: str, start_time: float,
meta: Optional[TrackMeta] = None,
- cover: Optional[Path] = None) -> bool:
+ cover: Optional[Path] = None,
+ debug_dir: Optional[Path] = None) -> bool:
"""Convert each chapter to audio, then assemble a single m4b with
embedded chapter markers.
Chapters are synthesized to lossless WAV scratch files (~170 MB per
- hour of audio) so the final AAC pass is the only lossy encode.
+ hour of audio) so the final AAC pass is the only lossy encode. When
+ ``debug_dir`` is given, each chapter's debug dumps land in its own
+ subfolder (chunk numbering restarts per chapter).
"""
chapter_files = []
titles = []
@@ -246,7 +304,8 @@ class AudiobookConverter:
logger.info("Converting chapter %d/%d: %s", index, total_chapters, title)
if not self._convert_text(section.text, chapter_path, time.time(),
speed=1.0, output_format="wav",
- chapter=(index, total_chapters)):
+ chapter=(index, total_chapters),
+ debug_dir=self._chapter_debug_dir(debug_dir, index, title)):
logger.warning("Skipping chapter %d (%s) due to conversion failure",
index, title)
continue
@@ -267,11 +326,14 @@ class AudiobookConverter:
print(f"[SUCCESS] Conversion completed in {int(duration // 60)}m {int(duration % 60)}s")
return True
- def _synthesize_chunks(self, chunks: List[str]) -> Dict[int, Optional[Path]]:
+ def _synthesize_chunks(self, chunks: List[str],
+ debug_dir: Optional[Path] = None) -> Dict[int, Optional[Path]]:
"""Synthesize chunks sequentially, preserving order and naming.
Returns a mapping of chunk number to the generated audio path, with
- None for chunks that failed after retries.
+ None for chunks that failed after retries. When ``debug_dir`` is
+ given (--debug), each chunk's request text and returned audio are
+ also dumped there, and every request/response is logged.
"""
total_chunks = len(chunks)
print(f"\n{'=' * 50}")
@@ -280,11 +342,23 @@ class AudiobookConverter:
results: Dict[int, Optional[Path]] = {}
for chunk_num, chunk_text in enumerate(chunks, 1):
+ if debug_dir is not None:
+ # Written before the request so the exact text survives a
+ # crash mid-generation; failed chunks keep their dumps.
+ self._write_debug_text(debug_dir, chunk_num, chunk_text)
+ logger.debug("Chunk %d/%d request text: %s", chunk_num, total_chunks, chunk_text)
+ request_start = time.time()
try:
result = self.tts.process_chunk_with_retry(chunk_num, chunk_text)
results[chunk_num] = result
if result:
+ if debug_dir is not None:
+ copied = self._copy_debug_audio(debug_dir, chunk_num, Path(result))
+ elapsed = time.time() - request_start
+ destination = f" -> {copied.name}" if copied else ""
+ logger.debug("Chunk %d/%d response in %.1fs%s",
+ chunk_num, total_chunks, elapsed, destination)
print(f"[OK] Chunk {chunk_num:3d}/{total_chunks} completed")
logger.info("+ Chunk %d/%d completed", chunk_num, total_chunks)
else:
@@ -309,12 +383,14 @@ class AudiobookConverter:
output_format: Optional[str] = None,
chapter: Optional[Tuple[int, int]] = None,
meta: Optional[TrackMeta] = None,
- cover: Optional[Path] = None) -> bool:
+ cover: Optional[Path] = None,
+ debug_dir: Optional[Path] = None) -> bool:
"""Chunk, synthesize, and assemble ``text`` into ``output_path``.
When ``chapter`` (a ``(number, total)`` pair) is given, the output is
an intermediate per-chapter file and progress messages are phrased
- accordingly instead of implying the whole book is done.
+ accordingly instead of implying the whole book is done. ``debug_dir``
+ (from --debug) receives the chunks' text and audio dumps.
"""
if speed is None:
speed = self.speed
@@ -341,7 +417,7 @@ class AudiobookConverter:
logger.info("Split into %d chunks (avg %.0f words per chunk)", total_chunks, avg_chunk_size)
print(f"[INFO] Processing {total_chunks} chunks via Qwen API...")
- results = self._synthesize_chunks(chunks)
+ results = self._synthesize_chunks(chunks, debug_dir=debug_dir)
successful_chunks = sum(1 for path in results.values() if path)
if successful_chunks == 0:
@@ -409,6 +485,8 @@ class AudiobookConverter:
print("Chapter mode: single file (--single-file)")
if abs(self.speed - 1.0) >= 1e-6:
print(f"Playback speed: {self.speed:g}x")
+ if self.debug:
+ print(f"Debug dumps (per-chunk text + raw audio): {config.DEBUG_FOLDER}")
print("=" * 70)
def run(self) -> bool: