diff options
Diffstat (limited to 'converter/converter.py')
| -rw-r--r-- | converter/converter.py | 108 |
1 files changed, 93 insertions, 15 deletions
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: |
