diff options
| author | historia <historiavg@proton.me> | 2026-08-17 18:33:57 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-17 19:01:55 -0400 |
| commit | 98c592fadf2c7dd7ce7f9d57ec254212a813c350 (patch) | |
| tree | 91cf344dd462588fd8279e69739505209d3ecbf5 /converter/audio.py | |
| parent | b4025ca7adb64ad4cfdbac62ea59765fbe76b8e6 (diff) | |
| download | tts-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.py | 139 |
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) |
