aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-19 02:44:04 -0400
committerhistoria <historiavg@proton.me>2026-08-19 02:44:04 -0400
commit24cb17a35ebaf5829cefa943c5a528d4accd7dfc (patch)
tree3f322308c4192e7ec8fd7d1e70acfb92ff33b252
parent11384921323f7b08271be0607d8df774489a1f7d (diff)
downloadtts-audiobook-generator-24cb17a35ebaf5829cefa943c5a528d4accd7dfc.tar.gz
feat: --debug flag
-rw-r--r--.gitignore1
-rw-r--r--README.md1
-rwxr-xr-xaudiobook.py11
-rw-r--r--converter/config.py1
-rw-r--r--converter/converter.py108
-rw-r--r--tests/test_converter.py107
6 files changed, 212 insertions, 17 deletions
diff --git a/.gitignore b/.gitignore
index 7758720..e636c70 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@ voices/
chunks/
cache/
logs/
+debug/
output/*
!output/.gitkeep
input/*
diff --git a/README.md b/README.md
index 0913632..3bd999f 100644
--- a/README.md
+++ b/README.md
@@ -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: