"""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 from . import config logger = logging.getLogger(__name__) def atempo_filters(speed: float) -> str: """Return a comma-joined ffmpeg ``atempo`` filter chain for ``speed``. ``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 "" remaining = float(speed) chain = [] while remaining > 2.0: chain.append("atempo=2.0") remaining /= 2.0 while remaining < 0.5: chain.append("atempo=0.5") remaining /= 0.5 chain.append(f"atempo={remaining:g}") 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 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. Chunks are streamed by ffmpeg, so the whole book is never held in memory. """ if shutil.which("ffmpeg") is None: logger.error("ffmpeg is required to combine audio chunks (install ffmpeg)") return False 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) 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") filters = atempo_filters(speed) if filters: speed_path = output_path.with_name(f"{output_path.stem}_{speed:g}{output_path.suffix}") 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_*"): try: 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) if chunk_count > 0: logger.info("Cleaned up %d chunk files", chunk_count) print(f"[INFO] Cleaned up {chunk_count} chunk files") except Exception as exc: logger.warning("Cleanup failed: %s", exc)