diff options
| author | historia <historiavg@proton.me> | 2026-08-18 02:17:03 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-18 02:17:03 -0400 |
| commit | 8760626f744d79a05904e692119ffa72fc0c4e54 (patch) | |
| tree | 1679faa3af8a1dd2ff1a94504168831ccf82f0ad | |
| parent | d72d274bd9895bdf97d31d56690b7df07fa74ad4 (diff) | |
| download | tts-audiobook-generator-8760626f744d79a05904e692119ffa72fc0c4e54.tar.gz | |
fix(converter): escape ffmetadata titles, thread exact chunk paths
| -rwxr-xr-x | audiobook_converter.py | 14 | ||||
| -rw-r--r-- | converter/audio.py | 117 | ||||
| -rw-r--r-- | converter/config.py | 8 | ||||
| -rw-r--r-- | converter/converter.py | 106 | ||||
| -rw-r--r-- | converter/tts.py | 32 |
5 files changed, 184 insertions, 93 deletions
diff --git a/audiobook_converter.py b/audiobook_converter.py index 36bd5c2..82eae9a 100755 --- a/audiobook_converter.py +++ b/audiobook_converter.py @@ -18,11 +18,9 @@ if sys.platform == "win32": sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") except AttributeError: - # Python < 3.7 - import codecs - sys.stdout = codecs.getwriter("utf-8")(sys.stdout.buffer, "strict") - sys.stderr = codecs.getwriter("utf-8")(sys.stderr.buffer, "strict") + pass +from converter import config from converter.converter import AudiobookConverter, setup_directories, setup_logging @@ -78,9 +76,9 @@ Examples: parser.add_argument( "--format", - choices=["mp3", "m4b"], - default="mp3", - help="Output container format (default: mp3). m4b uses AAC audio." + choices=list(config.AUDIO_FORMATS), + default=config.AUDIO_FORMAT, + help=f"Output container format (default: {config.AUDIO_FORMAT}). m4b uses AAC audio." ) parser.add_argument( @@ -110,7 +108,7 @@ Examples: try: converter = AudiobookConverter( - voice_mode="voice_clone" if args.voice_clone else "custom_voice", + voice_mode=config.VOICE_MODE_CLONE if args.voice_clone else config.VOICE_MODE_CUSTOM, voice_clone_ref_audio=args.voice_sample if args.voice_clone else None, voice_clone_ref_text=args.voice_sample_text if args.voice_clone else None, skip_transcription=args.no_transcription, diff --git a/converter/audio.py b/converter/audio.py index 9912004..f1c508c 100644 --- a/converter/audio.py +++ b/converter/audio.py @@ -1,11 +1,12 @@ """Audio assembly: combining chunks, speed adjustment, cleanup.""" import logging +import re import shutil import subprocess import traceback from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from . import config @@ -101,6 +102,8 @@ def build_concat_command(concat_list: Path, output_path: Path, output_format: st container = _m4b_container_args() if output_format == "m4b" else [] filters = atempo_filters(speed) if filters: + if speed_path is None: + raise ValueError("speed_path is required when speed is not 1.0") return [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-filter_complex", @@ -177,33 +180,50 @@ def verify_output_duration(path: Path, expected_ms: int) -> bool: return True -def combine_chunks(total_chunks: int, output_path: Path, - results: Optional[Dict[int, bool]] = None, speed: float = 1.0, - output_format: str = "mp3") -> bool: - """Combine audio chunks into the final audiobook using ffmpeg's concat demuxer. +def _collect_chunk_files(total_chunks: int, + chunk_results: Optional[Dict[int, Optional[Path]]] = None + ) -> Tuple[List[Path], List[int]]: + """Resolve chunk audio files in book order. - ``results`` maps chunk numbers to success flags; failed chunks are - skipped. When ``speed`` differs from 1.0, an additional speed-adjusted - copy is written next to the normal-speed file. Chunks are streamed by - ffmpeg, so the whole book is never held in memory. + When ``chunk_results`` is provided (chunk number -> path written, or None + for a failed chunk), the recorded paths are used exactly as-is so stale + files from a previous chapter can never leak in. Without it, the chunks + folder is globbed per index (legacy discovery). """ - if shutil.which("ffmpeg") is None: - logger.error("ffmpeg is required to combine audio chunks (install ffmpeg)") - return False - - chunk_files = [] - missing_chunks = [] + chunk_files: List[Path] = [] + missing: List[int] = [] for i in range(1, total_chunks + 1): - # Skip chunks that failed if we have results tracking - if results is not None and not results.get(i, False): - missing_chunks.append(i) + if chunk_results is not None: + recorded = chunk_results.get(i) + if recorded is not None and Path(recorded).exists(): + chunk_files.append(Path(recorded)) + else: + missing.append(i) continue - matches = sorted(config.CHUNKS_FOLDER.glob(f"chunk_{i:04d}.*")) if matches: chunk_files.append(matches[0]) else: - missing_chunks.append(i) + missing.append(i) + return chunk_files, missing + + +def combine_chunks(total_chunks: int, output_path: Path, + chunk_results: Optional[Dict[int, Optional[Path]]] = None, + speed: float = 1.0, output_format: str = "mp3") -> bool: + """Combine audio chunks into the final audiobook using ffmpeg's concat demuxer. + + ``chunk_results`` maps chunk numbers to the audio file each chunk produced + (None for failed chunks); failed and missing chunks are skipped. When + ``speed`` differs from 1.0, an additional speed-adjusted copy is written + next to the normal-speed file. Chunks are streamed by ffmpeg, so the whole + book is never held in memory. + """ + if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None: + logger.error("ffmpeg and ffprobe are required to combine audio chunks (install ffmpeg)") + return False + + chunk_files, missing_chunks = _collect_chunk_files(total_chunks, chunk_results) if not chunk_files: logger.error("No valid chunks found") @@ -231,10 +251,17 @@ def combine_chunks(total_chunks: int, output_path: Path, # Verify the assembled duration against the sum of chunk durations # so corrupt output is caught before it reaches audiobook players. - expected_ms = sum(probe_duration_ms(chunk_file) for chunk_file in chunk_files) - duration_ok = verify_output_duration(output_path, expected_ms) - if speed_path is not None: - duration_ok = verify_output_duration(speed_path, int(expected_ms / speed)) and duration_ok + # A failed probe returns 0, which would deflate the expected total + # and falsely fail the check, so unverifiable sums skip it. + durations = [probe_duration_ms(chunk_file) for chunk_file in chunk_files] + if any(duration <= 0 for duration in durations): + logger.warning("Could not probe every chunk duration; skipping duration verification") + duration_ok = True + else: + expected_ms = sum(durations) + duration_ok = verify_output_duration(output_path, expected_ms) + if speed_path is not None: + duration_ok = verify_output_duration(speed_path, int(expected_ms / speed)) and duration_ok if not duration_ok: return False @@ -286,11 +313,15 @@ def cleanup_chunks() -> None: def probe_duration_ms(path: Path) -> int: """Return audio duration in milliseconds using ffprobe.""" - result = subprocess.run( - ["ffprobe", "-v", "error", "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", str(path)], - capture_output=True, text=True, - ) + try: + result = subprocess.run( + ["ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", str(path)], + capture_output=True, text=True, timeout=30, + ) + except subprocess.TimeoutExpired: + logger.warning("ffprobe timed out for %s", path) + return 0 if result.returncode != 0: logger.warning("ffprobe failed for %s: %s", path, result.stderr[-200:]) return 0 @@ -301,6 +332,18 @@ def probe_duration_ms(path: Path) -> int: return 0 +def _escape_ffmetadata_value(value: str) -> str: + """Escape a metadata value for ffmpeg's FFMETADATA format. + + Backslash and the structural characters ``=``, ``;`` and ``#`` must be + backslash-escaped; line breaks would corrupt the file and are collapsed + to spaces. + """ + value = value.replace("\\", "\\\\") + value = re.sub(r"[\r\n]+", " ", value) + return re.sub(r"[=;#]", r"\\\g<0>", value) + + def build_ffmetadata(chapters: List[tuple], path: Path) -> None: """Write an ffmpeg FFMETADATA file with ``[CHAPTER]`` entries. @@ -313,7 +356,7 @@ def build_ffmetadata(chapters: List[tuple], path: Path) -> None: meta_file.write("TIMEBASE=1/1000\n") meta_file.write(f"START={int(start_ms)}\n") meta_file.write(f"END={int(end_ms)}\n") - meta_file.write(f"title={title}\n") + meta_file.write(f"title={_escape_ffmetadata_value(title)}\n") def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str], @@ -364,11 +407,17 @@ def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str], logger.error("ffmpeg failed: %s", proc.stderr[-2000:]) return False - # The last chapter's end time is the expected total duration. + # The last chapter's end time is the expected total duration; skip + # verification when any chapter duration probe failed (returned 0) + # so an unprobed chapter can't falsely fail the whole output. expected_ms = chapters[-1][1] - duration_ok = verify_output_duration(output_path, expected_ms) - if speed_path is not None: - duration_ok = verify_output_duration(speed_path, int(expected_ms / speed)) and duration_ok + if any(end_ms - start_ms <= 0 for start_ms, end_ms, _ in chapters): + logger.warning("Could not probe every chapter duration; skipping duration verification") + duration_ok = True + else: + duration_ok = verify_output_duration(output_path, expected_ms) + if speed_path is not None: + duration_ok = verify_output_duration(speed_path, int(expected_ms / speed)) and duration_ok if not duration_ok: return False diff --git a/converter/config.py b/converter/config.py index 5542bae..b4d7f22 100644 --- a/converter/config.py +++ b/converter/config.py @@ -11,6 +11,14 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent # ============================================================================= +# VOICE MODES +# ============================================================================= + +VOICE_MODE_CUSTOM = "custom_voice" +VOICE_MODE_CLONE = "voice_clone" +VOICE_MODES = (VOICE_MODE_CUSTOM, VOICE_MODE_CLONE) + +# ============================================================================= # QWEN API CONFIGURATION # ============================================================================= diff --git a/converter/converter.py b/converter/converter.py index 0454892..d5a1096 100644 --- a/converter/converter.py +++ b/converter/converter.py @@ -5,9 +5,10 @@ import re import sys import time import traceback +from collections import Counter from datetime import datetime from pathlib import Path -from typing import Dict, Optional +from typing import Dict, List, Optional from . import audio, chunking, config, extractors from .tts import QwenTTSClient @@ -41,7 +42,7 @@ def setup_directories() -> None: class AudiobookConverter: """Audiobook converter using the Qwen TTS API.""" - def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None, + def __init__(self, voice_mode: str = config.VOICE_MODE_CUSTOM, voice_clone_ref_audio: Optional[str] = None, voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False, speed: float = 1.0, single_file: bool = False, output_format: str = "mp3"): if speed <= 0: @@ -63,7 +64,12 @@ class AudiobookConverter: def _validate_configuration(self) -> None: """Validate configuration settings.""" - if self.voice_mode == "voice_clone": + if self.voice_mode not in config.VOICE_MODES: + raise ValueError( + f"Unknown voice mode: {self.voice_mode!r} " + f"(expected one of {config.VOICE_MODES})" + ) + if self.voice_mode == config.VOICE_MODE_CLONE: if not self.voice_clone_ref_audio: raise ValueError( "Voice Clone mode requires a reference audio file. " @@ -117,7 +123,7 @@ class AudiobookConverter: for index, section in enumerate(sections, 1): chapter_name = f"{stem}_{index:02d}_{self._sanitize_filename(section.title)}" output_path = config.AUDIOBOOKS_FOLDER / f"{chapter_name}.{self.output_format}" - success = self._convert_text(section.text, output_path, start_time) and success + success = self._convert_text(section.text, output_path, time.time()) and success return success except Exception as exc: @@ -139,7 +145,7 @@ class AudiobookConverter: titles = [] for index, section in enumerate(sections, 1): chapter_path = config.CHUNKS_FOLDER / f"chapter_{index:04d}.wav" - if not self._convert_text(section.text, chapter_path, start_time, + if not self._convert_text(section.text, chapter_path, time.time(), speed=1.0, output_format="wav"): logger.warning("Skipping chapter %d (%s) due to conversion failure", index, section.title) @@ -152,7 +158,50 @@ class AudiobookConverter: return False output_path = config.AUDIOBOOKS_FOLDER / f"{stem}.{self.output_format}" - return audio.combine_chapters_to_m4b(chapter_files, titles, output_path, speed=self.speed) + if not audio.combine_chapters_to_m4b(chapter_files, titles, output_path, speed=self.speed): + return False + duration = time.time() - start_time + logger.info("Conversion completed in %dm %ds: %s", + int(duration // 60), int(duration % 60), output_path) + 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]]: + """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. + """ + total_chunks = len(chunks) + print(f"\n{'=' * 50}") + print(f"PROCESSING {total_chunks} CHUNKS") + print(f"{'=' * 50}") + + results: Dict[int, Optional[Path]] = {} + for chunk_num, chunk_text in enumerate(chunks, 1): + try: + result = self.tts.process_chunk_with_retry(chunk_num, chunk_text) + results[chunk_num] = result + + if result: + print(f"[OK] Chunk {chunk_num:3d}/{total_chunks} completed") + logger.info("+ Chunk %d/%d completed", chunk_num, total_chunks) + else: + print(f"[FAIL] Chunk {chunk_num:3d}/{total_chunks} FAILED") + logger.error("- Chunk %d/%d failed", chunk_num, total_chunks) + + except Exception as exc: + results[chunk_num] = None + print(f"[ERROR] Chunk {chunk_num:3d}/{total_chunks} ERROR: {exc}") + logger.error("- Chunk %d/%d error: %s", chunk_num, total_chunks, exc) + + successful_chunks = sum(1 for path in results.values() if path) + print(f"\n{'=' * 50}") + print("CHUNK PROCESSING COMPLETE") + print(f"Successful: {successful_chunks}/{total_chunks}") + print(f"{'=' * 50}") + logger.info("Qwen processing completed: %d/%d chunks", successful_chunks, total_chunks) + return results def _convert_text(self, text: str, output_path: Path, start_time: float, speed: Optional[float] = None, @@ -184,36 +233,8 @@ class AudiobookConverter: print(f"[INFO] Processing {total_chunks} chunks via Qwen API...") print(f"[INFO] Estimated time: ~{total_chunks * 4} minutes (4 min per chunk)") - print(f"\n{'=' * 50}") - print(f"PROCESSING {total_chunks} CHUNKS") - print(f"{'=' * 50}") - - # Process chunks sequentially to ensure correct order and naming: - # chunks are named 1, 2, 3, 4... in order. - results = {} # chunk_num -> success (bool) - for chunk_num, chunk_text in enumerate(chunks, 1): - try: - result = self.tts.process_chunk_with_retry(chunk_num, chunk_text) - results[chunk_num] = result - - if result: - print(f"[OK] Chunk {chunk_num:3d}/{total_chunks} completed") - logger.info("+ Chunk %d/%d completed", chunk_num, total_chunks) - else: - print(f"[FAIL] Chunk {chunk_num:3d}/{total_chunks} FAILED") - logger.error("- Chunk %d/%d failed", chunk_num, total_chunks) - - except Exception as exc: - results[chunk_num] = False - print(f"[ERROR] Chunk {chunk_num:3d}/{total_chunks} ERROR: {exc}") - logger.error("- Chunk %d/%d error: %s", chunk_num, total_chunks, exc) - - successful_chunks = sum(1 for v in results.values() if v) - print(f"\n{'=' * 50}") - print("CHUNK PROCESSING COMPLETE") - print(f"Successful: {successful_chunks}/{total_chunks}") - print(f"{'=' * 50}") - logger.info("Qwen processing completed: %d/%d chunks", successful_chunks, total_chunks) + results = self._synthesize_chunks(chunks) + successful_chunks = sum(1 for path in results.values() if path) if successful_chunks == 0: logger.error("No chunks were successfully processed") @@ -224,7 +245,7 @@ class AudiobookConverter: successful_chunks, total_chunks) # Combine chunks (only the successful ones) - success = audio.combine_chunks(total_chunks, output_path, results, + success = audio.combine_chunks(total_chunks, output_path, chunk_results=results, speed=speed, output_format=output_format) if success: @@ -245,7 +266,7 @@ class AudiobookConverter: def run(self) -> bool: """Main conversion process. Returns True if all books converted.""" - api_url = config.VOICE_CLONE_API_URL if self.voice_mode == "voice_clone" else config.QWEN_API_URL + api_url = config.VOICE_CLONE_API_URL if self.voice_mode == config.VOICE_MODE_CLONE else config.QWEN_API_URL print("=" * 70) print("QWEN-BASED AUDIOBOOK CONVERTER") @@ -255,10 +276,10 @@ class AudiobookConverter: print(f"Qwen API endpoint: {api_url}") print(f"Voice mode: {self.voice_mode}") print("Model size: 1.7B (always)") - if self.voice_mode == "custom_voice": + if self.voice_mode == config.VOICE_MODE_CUSTOM: print(f"Speaker: {config.CUSTOM_VOICE_SPEAKER}") print(f"Language: {config.CUSTOM_VOICE_LANGUAGE}") - elif self.voice_mode == "voice_clone": + elif self.voice_mode == config.VOICE_MODE_CLONE: print(f"Reference audio: {Path(self.voice_clone_ref_audio).name}") print(f"Language: {config.VOICE_CLONE_LANGUAGE}") print(f"Output format: {self.output_format}") @@ -292,9 +313,7 @@ class AudiobookConverter: print(f"[INFO] Found {len(book_files)} books to convert") # Avoid output collisions when two books share a stem (e.g. dune.txt + dune.epub). - stem_counts: Dict[str, int] = {} - for book_file in book_files: - stem_counts[book_file.stem] = stem_counts.get(book_file.stem, 0) + 1 + stem_counts: Dict[str, int] = Counter(book_file.stem for book_file in book_files) # Convert each book results = {} @@ -307,6 +326,7 @@ class AudiobookConverter: results[book_file.name] = success except KeyboardInterrupt: print("\n[WARNING] Conversion interrupted by user") + results[book_file.name] = False break except Exception as exc: logger.error("Unexpected error: %s", exc) diff --git a/converter/tts.py b/converter/tts.py index c3a4b81..83c7330 100644 --- a/converter/tts.py +++ b/converter/tts.py @@ -20,6 +20,10 @@ class QwenTTSClient: def __init__(self, voice_mode: str = "custom_voice", voice_clone_ref_audio: Optional[str] = None, voice_clone_ref_text: Optional[str] = None, skip_transcription: bool = False): + if voice_mode not in config.VOICE_MODES: + raise ValueError( + f"Unknown voice mode: {voice_mode!r} (expected one of {config.VOICE_MODES})" + ) self.voice_mode = voice_mode self.voice_clone_ref_audio = voice_clone_ref_audio self.voice_clone_ref_text = (voice_clone_ref_text or "").strip() @@ -36,9 +40,9 @@ class QwenTTSClient: # ------------------------------------------------------------------ def _connect(self) -> None: - api_url = config.VOICE_CLONE_API_URL if self.voice_mode == "voice_clone" else config.QWEN_API_URL + api_url = config.VOICE_CLONE_API_URL if self.voice_mode == config.VOICE_MODE_CLONE else config.QWEN_API_URL try: - if self.voice_mode == "voice_clone": + if self.voice_mode == config.VOICE_MODE_CLONE: # Voice clone uses the Base-model demo, which is a separate server # from the CustomVoice demo (that one only exposes /run_instruct). self._init_client(config.VOICE_CLONE_API_URL, clone=True) @@ -159,10 +163,10 @@ class QwenTTSClient: def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]: """Generate one audio chunk; returns its path in the chunks folder.""" try: - if self.voice_mode == "custom_voice": + if self.voice_mode == config.VOICE_MODE_CUSTOM: with self._chunk_heartbeat(chunk_num): result = self._generate_custom_voice(text) - elif self.voice_mode == "voice_clone": + elif self.voice_mode == config.VOICE_MODE_CLONE: with self._chunk_heartbeat(chunk_num): result = self._generate_voice_clone(text) else: @@ -180,6 +184,13 @@ class QwenTTSClient: raise RuntimeError(f"Generated audio file not found: {audio_path}") suffix = source.suffix or ".wav" + # Remove any stale chunk file for this index first so a retry or + # extension change can never leave two files matching chunk_NNNN.* + for stale in config.CHUNKS_FOLDER.glob(f"chunk_{chunk_num:04d}.*"): + try: + stale.unlink() + except OSError as exc: + logger.debug("Could not remove stale chunk file %s: %s", stale, exc) output_path = config.CHUNKS_FOLDER / f"chunk_{chunk_num:04d}{suffix}" shutil.copy2(source, output_path) @@ -190,8 +201,12 @@ class QwenTTSClient: logger.error("Qwen chunk processing failed for chunk %d: %s", chunk_num, exc) return None - def process_chunk_with_retry(self, chunk_num: int, text: str) -> bool: - """Process a chunk with retry logic and rate limiting.""" + def process_chunk_with_retry(self, chunk_num: int, text: str) -> Optional[Path]: + """Process a chunk with retry logic and rate limiting. + + Returns the generated chunk file's path, or None when all attempts + failed. + """ # Small delay between chunks to avoid rate limiting (only if not first chunk) if chunk_num > 1: time.sleep(config.MIN_DELAY_BETWEEN_CHUNKS) @@ -200,7 +215,7 @@ class QwenTTSClient: try: result = self.generate_chunk(text, chunk_num) if result and Path(result).exists(): - return True + return Path(result) logger.warning("Chunk %d attempt %d failed", chunk_num, attempt + 1) except Exception as exc: logger.warning("Chunk %d attempt %d error: %s", chunk_num, attempt + 1, exc) @@ -211,7 +226,7 @@ class QwenTTSClient: time.sleep(sleep_time) logger.error("Chunk %d failed after %d attempts", chunk_num, config.MAX_RETRIES) - return False + return None @contextlib.contextmanager def _chunk_heartbeat(self, chunk_num: int): @@ -231,6 +246,7 @@ class QwenTTSClient: yield finally: stop.set() + thread.join() # ------------------------------------------------------------------ # API payloads |
