aboutsummaryrefslogtreecommitdiff
path: root/converter/audio.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-17 18:33:57 -0400
committerhistoria <historiavg@proton.me>2026-08-17 19:01:55 -0400
commit98c592fadf2c7dd7ce7f9d57ec254212a813c350 (patch)
tree91cf344dd462588fd8279e69739505209d3ecbf5 /converter/audio.py
parentb4025ca7adb64ad4cfdbac62ea59765fbe76b8e6 (diff)
downloadtts-audiobook-generator-98c592fadf2c7dd7ce7f9d57ec254212a813c350.tar.gz
fix(converter): stream audio concat and harden error/encoding handling
Diffstat (limited to 'converter/audio.py')
-rw-r--r--converter/audio.py139
1 files changed, 88 insertions, 51 deletions
diff --git a/converter/audio.py b/converter/audio.py
index ab55e9b..e7fa0bb 100644
--- a/converter/audio.py
+++ b/converter/audio.py
@@ -1,6 +1,8 @@
"""Audio assembly: combining chunks, speed adjustment, cleanup."""
import logging
+import shutil
+import subprocess
import traceback
from pathlib import Path
from typing import Dict, List, Optional
@@ -10,16 +12,16 @@ from . import config
logger = logging.getLogger(__name__)
-def speed_export_params(speed: float) -> List[str]:
- """Return ffmpeg filter args for pitch-preserving speed adjustment.
+def atempo_filters(speed: float) -> str:
+ """Return a comma-joined ffmpeg ``atempo`` filter chain for ``speed``.
- Uses ffmpeg's atempo filter, which accepts 0.5..2.0 per filter. Values
- outside that range are handled by chaining multiple atempo filters.
+ ``atempo`` accepts 0.5..2.0 per filter; values outside that range are
+ handled by chaining multiple filters. Returns "" when ``speed`` is 1.0.
"""
if speed <= 0:
raise ValueError(f"Speed must be a positive number, got {speed}")
if abs(speed - 1.0) < 1e-6:
- return []
+ return ""
remaining = float(speed)
chain = []
while remaining > 2.0:
@@ -29,84 +31,119 @@ def speed_export_params(speed: float) -> List[str]:
chain.append("atempo=0.5")
remaining /= 0.5
chain.append(f"atempo={remaining:g}")
- return ["-filter:a", ",".join(chain)]
+ return ",".join(chain)
+
+
+def speed_export_params(speed: float) -> List[str]:
+ """Return ffmpeg filter args for pitch-preserving speed adjustment."""
+ filters = atempo_filters(speed)
+ if not filters:
+ return []
+ return ["-filter:a", filters]
+
+
+def _concat_escape(path: str) -> str:
+ """Escape a path for use inside single quotes in an ffmpeg concat list."""
+ return path.replace("'", "'\\''")
def combine_chunks(total_chunks: int, output_path: Path,
results: Optional[Dict[int, bool]] = None, speed: float = 1.0) -> bool:
- """Combine audio chunks into the final audiobook.
+ """Combine audio chunks into the final audiobook using ffmpeg's concat demuxer.
``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.
+ copy is written next to the normal-speed file. Chunks are streamed by
+ ffmpeg, so the whole book is never held in memory.
"""
- try:
- from pydub import AudioSegment
- except ImportError:
- logger.error("pydub is required to combine audio chunks (pip install pydub)")
+ if shutil.which("ffmpeg") is None:
+ logger.error("ffmpeg is required to combine audio chunks (install ffmpeg)")
return False
- try:
- combined = AudioSegment.empty()
- successful = 0
- missing_chunks = []
-
- 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)
- continue
-
- chunk_file = config.CHUNKS_FOLDER / f"chunk_{i:04d}.wav"
- if chunk_file.exists():
- try:
- combined += AudioSegment.from_wav(str(chunk_file))
- successful += 1
- if successful % 10 == 0:
- logger.info("Combined %d chunks", successful)
- except Exception as exc:
- logger.warning("Failed to load chunk %d: %s", i, exc)
- missing_chunks.append(i)
- else:
- logger.warning("Chunk file not found: %s", chunk_file)
- missing_chunks.append(i)
-
- if successful == 0:
- raise RuntimeError("No valid chunks found")
+ chunk_files = []
+ missing_chunks = []
+ 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)
+ continue
+
+ matches = sorted(config.CHUNKS_FOLDER.glob(f"chunk_{i:04d}.*"))
+ if matches:
+ chunk_files.append(matches[0])
+ else:
+ missing_chunks.append(i)
+
+ if not chunk_files:
+ logger.error("No valid chunks found")
+ return False
- if missing_chunks:
- logger.warning("Missing chunks: %s", missing_chunks)
+ if missing_chunks:
+ logger.warning("Missing chunks: %s", missing_chunks)
- combined.export(str(output_path), format=config.AUDIO_FORMAT, bitrate=config.AUDIO_BITRATE)
- logger.info("Audiobook saved: %s (%d/%d chunks)", output_path, successful, total_chunks)
- print(f"[INFO] Saved audiobook: {output_path.name} ({successful}/{total_chunks} chunks)")
+ concat_list = config.CHUNKS_FOLDER / "_concat_list.txt"
+ try:
+ with open(concat_list, "w", encoding="utf-8") as list_file:
+ for chunk_file in chunk_files:
+ list_file.write(f"file '{_concat_escape(str(chunk_file))}'\n")
- export_params = speed_export_params(speed)
- if export_params:
+ filters = atempo_filters(speed)
+ if filters:
speed_path = output_path.with_name(f"{output_path.stem}_{speed:g}{output_path.suffix}")
- combined.export(str(speed_path), format=config.AUDIO_FORMAT,
- bitrate=config.AUDIO_BITRATE, parameters=export_params)
+ cmd = [
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list),
+ "-filter_complex",
+ f"[0:a]split=2[base][spd];[spd]{filters}[spdout]",
+ "-map", "[base]", "-b:a", config.AUDIO_BITRATE, str(output_path),
+ "-map", "[spdout]", "-b:a", config.AUDIO_BITRATE, str(speed_path),
+ ]
+ else:
+ speed_path = None
+ cmd = [
+ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list),
+ "-b:a", config.AUDIO_BITRATE, str(output_path),
+ ]
+
+ proc = subprocess.run(cmd, capture_output=True, text=True)
+ if proc.returncode != 0:
+ logger.error("ffmpeg failed: %s", proc.stderr[-2000:])
+ return False
+
+ logger.info("Audiobook saved: %s (%d/%d chunks)", output_path, len(chunk_files), total_chunks)
+ print(f"[INFO] Saved audiobook: {output_path.name} ({len(chunk_files)}/{total_chunks} chunks)")
+
+ if filters:
logger.info("Saved speed-adjusted audiobook (%gx): %s", speed, speed_path)
print(f"[INFO] Saved speed-adjusted audiobook: {speed_path.name} ({speed:g}x)")
if missing_chunks:
print(f"[WARNING] Missing chunks: {missing_chunks}")
+
return True
+ except FileNotFoundError:
+ logger.error("ffmpeg not found on PATH (install ffmpeg and try again)")
+ return False
except Exception as exc:
logger.error("Failed to combine chunks: %s", exc)
logger.error(traceback.format_exc())
return False
+ finally:
+ try:
+ concat_list.unlink(missing_ok=True)
+ except OSError:
+ pass
def cleanup_chunks() -> None:
"""Remove temporary chunk files from the scratch folder."""
try:
chunk_count = 0
- for chunk_file in config.CHUNKS_FOLDER.glob("chunk_*.wav"):
+ for chunk_file in config.CHUNKS_FOLDER.glob("chunk_*"):
try:
- chunk_file.unlink()
- chunk_count += 1
+ if chunk_file.is_file():
+ chunk_file.unlink()
+ chunk_count += 1
except Exception as exc:
logger.warning("Failed to delete %s: %s", chunk_file, exc)