aboutsummaryrefslogtreecommitdiff
path: root/converter
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-18 02:17:03 -0400
committerhistoria <historiavg@proton.me>2026-08-18 02:17:03 -0400
commit8760626f744d79a05904e692119ffa72fc0c4e54 (patch)
tree1679faa3af8a1dd2ff1a94504168831ccf82f0ad /converter
parentd72d274bd9895bdf97d31d56690b7df07fa74ad4 (diff)
downloadtts-audiobook-generator-8760626f744d79a05904e692119ffa72fc0c4e54.tar.gz
fix(converter): escape ffmetadata titles, thread exact chunk paths
Diffstat (limited to 'converter')
-rw-r--r--converter/audio.py117
-rw-r--r--converter/config.py8
-rw-r--r--converter/converter.py106
-rw-r--r--converter/tts.py32
4 files changed, 178 insertions, 85 deletions
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