diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rwxr-xr-x | audiobook.py | 11 | ||||
| -rw-r--r-- | converter/config.py | 1 | ||||
| -rw-r--r-- | converter/converter.py | 108 | ||||
| -rw-r--r-- | tests/test_converter.py | 107 |
6 files changed, 212 insertions, 17 deletions
@@ -2,6 +2,7 @@ voices/ chunks/ cache/ logs/ +debug/ output/* !output/.gitkeep input/* @@ -159,6 +159,7 @@ python audiobook.py --faster [--faster-voice NAME] | `--language <lang>` | Output language for the synthesized speech. Can add an accent even if the text is English. | | `--faster` | Use a faster-qwen3-tts OpenAI-compatible server (up to 5x faster in certain cases). | | `--faster-voice <name>` | Chooses a voice from voices.json when using `--faster` with multiple voices. | +| `--debug` | Troubleshooting: dump each chunk's raw audio and sent text to `debug/` and log every request. | Other options and defaults are configured in `converter/config.py` diff --git a/audiobook.py b/audiobook.py index ed92e53..3c27aac 100755 --- a/audiobook.py +++ b/audiobook.py @@ -119,6 +119,14 @@ Examples: "or 'default' when the server was started with --ref-audio.") ) + parser.add_argument( + "--debug", + action="store_true", + help=("Troubleshooting mode: dump each chunk's raw audio and the exact text " + "sent for it under the debug/ folder (organized per book and chapter), " + "and log every TTS request and response to the console and log file.") + ) + args = parser.parse_args() if args.speed <= 0: @@ -151,7 +159,7 @@ Examples: print("[WARNING] --transcription/--no-transcription " "are ignored without --clone") - setup_logging() + setup_logging(debug=args.debug) setup_directories() try: @@ -167,6 +175,7 @@ Examples: language=args.language, faster=args.faster, faster_voice=args.faster_voice, + debug=args.debug, ) ok = converter.run() except KeyboardInterrupt: 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: diff --git a/tests/test_converter.py b/tests/test_converter.py index 48dc162..707688e 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -3,7 +3,7 @@ import tempfile import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch from converter import config from converter.converter import AudiobookConverter, find_existing_outputs, prompt_overwrite @@ -144,6 +144,110 @@ class NarratorTagTests(unittest.TestCase): "narrator") +class ChapterDebugDirTests(unittest.TestCase): + """Per-chapter debug subfolder naming (chunk numbering restarts per chapter).""" + + def test_none_when_not_debugging(self): + self.assertIsNone(AudiobookConverter._chapter_debug_dir(None, 3, "The Trial")) + + def test_chapter_subfolder_named_by_index_and_title(self): + book_dir = Path("debug") / "dune_Vivian" + chapter_dir = AudiobookConverter._chapter_debug_dir(book_dir, 3, "The Trial") + self.assertEqual(chapter_dir, book_dir / "03_The Trial") + + def test_untitled_chapter_uses_fallback(self): + chapter_dir = AudiobookConverter._chapter_debug_dir(Path("d"), 1, "") + self.assertEqual(chapter_dir, Path("d") / "01_chapter") + + +class DebugDumpTests(unittest.TestCase): + """--debug: per-chunk text/audio dumps and request/response logging.""" + + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self._debug_folder = patch.object(config, "DEBUG_FOLDER", Path(self._tmp.name)) + self._debug_folder.start() + self.debug_root = Path(self._tmp.name) + self.converter = AudiobookConverter.__new__(AudiobookConverter) + self.converter.tts = MagicMock() + + def tearDown(self): + self._debug_folder.stop() + self._tmp.cleanup() + + def _chunk_source(self, name, body=b"audio"): + path = self.debug_root / "sources" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(body) + return path + + def test_successful_chunk_dumps_text_and_audio(self): + audio = self._chunk_source("chunk_0001.wav") + self.converter.tts.process_chunk_with_retry.return_value = audio + results = self.converter._synthesize_chunks(["Hello world."], + debug_dir=self.debug_root / "book") + self.assertEqual(results, {1: audio}) + debug_dir = self.debug_root / "book" + self.assertEqual((debug_dir / "chunk_0001.txt").read_text(encoding="utf-8"), + "Hello world.") + self.assertEqual((debug_dir / "chunk_0001.wav").read_bytes(), b"audio") + + def test_failed_chunk_dumps_text_but_no_audio(self): + self.converter.tts.process_chunk_with_retry.return_value = None + results = self.converter._synthesize_chunks(["Hello again."], + debug_dir=self.debug_root / "book") + self.assertEqual(results, {1: None}) + debug_dir = self.debug_root / "book" + self.assertEqual([path.name for path in sorted(debug_dir.iterdir())], + ["chunk_0001.txt"]) + + def test_text_dumped_even_when_request_raises(self): + self.converter.tts.process_chunk_with_retry.side_effect = RuntimeError("boom") + results = self.converter._synthesize_chunks(["Crash text."], + debug_dir=self.debug_root / "book") + self.assertEqual(results, {1: None}) + self.assertEqual((self.debug_root / "book" / "chunk_0001.txt").read_text( + encoding="utf-8"), "Crash text.") + + def test_audio_suffix_preserved_and_nested_dirs_created(self): + audio = self._chunk_source("generated.mp3") + self.converter.tts.process_chunk_with_retry.return_value = audio + self.converter._synthesize_chunks(["Hello."], + debug_dir=self.debug_root / "nested" / "book") + self.assertTrue((self.debug_root / "nested" / "book" / "chunk_0001.mp3").exists()) + + def test_no_debug_dir_writes_nothing(self): + audio = self._chunk_source("chunk_0001.wav") + self.converter.tts.process_chunk_with_retry.return_value = audio + results = self.converter._synthesize_chunks(["Hello world."]) + self.assertEqual(results, {1: audio}) + self.assertEqual([path.name for path in self.debug_root.iterdir()], ["sources"]) + + def test_request_and_response_are_logged(self): + audio = self._chunk_source("chunk_0001.wav") + self.converter.tts.process_chunk_with_retry.return_value = audio + with self.assertLogs("converter.converter", level="DEBUG") as logs: + self.converter._synthesize_chunks(["Hello world."], + debug_dir=self.debug_root / "book") + joined = "\n".join(logs.output) + self.assertIn("Chunk 1/1 request text: Hello world.", joined) + self.assertIn("Chunk 1/1 response in", joined) + self.assertIn("chunk_0001.wav", joined) + + def test_debug_write_failure_does_not_abort_conversion(self): + blocker = self.debug_root / "blocker" + blocker.write_bytes(b"") + audio = self._chunk_source("chunk_0001.wav") + self.converter.tts.process_chunk_with_retry.return_value = audio + results = self.converter._synthesize_chunks(["Hello."], debug_dir=blocker / "book") + self.assertEqual(results, {1: audio}) + + def test_debug_flag_wiring(self): + with patch("converter.converter.QwenTTSClient"): + self.assertFalse(AudiobookConverter().debug) + self.assertTrue(AudiobookConverter(debug=True).debug) + + class PromptOverwriteTests(unittest.TestCase): def test_single_file_yes(self): with patch("builtins.input", return_value="y"): @@ -197,6 +301,7 @@ class RunOverwritePromptTests(unittest.TestCase): self.converter.single_file = False self.converter.output_format = "mp3" self.converter.language = "English" + self.converter.debug = False self.converted = [] self.converter.convert_book = ( lambda file_path, output_name=None: |
