"""Audio assembly: combining chunks, speed adjustment, cleanup.""" import logging import traceback from pathlib import Path from typing import Dict, List, Optional from . import config logger = logging.getLogger(__name__) def speed_export_params(speed: float) -> List[str]: """Return ffmpeg filter args for pitch-preserving speed adjustment. Uses ffmpeg's atempo filter, which accepts 0.5..2.0 per filter. Values outside that range are handled by chaining multiple atempo filters. """ 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 ["-filter:a", ",".join(chain)] 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. ``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. """ try: from pydub import AudioSegment except ImportError: logger.error("pydub is required to combine audio chunks (pip install pydub)") 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") 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)") export_params = speed_export_params(speed) if export_params: 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) 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 Exception as exc: logger.error("Failed to combine chunks: %s", exc) logger.error(traceback.format_exc()) return False 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"): try: 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)