"""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 _encode_args(output_format: str) -> List[str]: """Return ffmpeg output codec/bitrate args for the requested container.""" if output_format == "m4b": return ["-c:a", "aac", "-b:a", config.AUDIO_BITRATE] if output_format == "wav": # Lossless intermediate for per-chapter scratch audio; avoids # generational loss when the final m4b re-encodes to AAC. return ["-c:a", "pcm_s16le"] return ["-b:a", config.AUDIO_BITRATE] _brand_supported: Optional[bool] = None def _detect_brand_support() -> bool: """Check whether the local ffmpeg muxer accepts the ``-brand`` option.""" if shutil.which("ffmpeg") is None: return False try: proc = subprocess.run( ["ffmpeg", "-hide_banner", "-h", "muxer=ipod"], capture_output=True, text=True, timeout=15, ) except (OSError, subprocess.TimeoutExpired): return False return "-brand" in proc.stdout def _m4b_container_args() -> List[str]: """Return per-output container flags for m4b files. ``+faststart`` moves the ``moov`` index to the front of the file so streaming players (and naive linear readers like web players) can index it; without it they may misreport the duration or refuse the file. The ``M4B `` major brand identifies the file as an audiobook to players that sniff brands instead of trusting the extension (the ffmpeg default brand for .m4b is ``M4A ``). """ global _brand_supported if _brand_supported is None: _brand_supported = _detect_brand_support() args = ["-movflags", "+faststart"] if _brand_supported: args += ["-brand", "M4B "] return args def build_concat_command(concat_list: Path, output_path: Path, output_format: str, speed: float = 1.0, speed_path: Optional[Path] = None) -> List[str]: """Build the ffmpeg command that concatenates chunk audio into a book file.""" encode = _encode_args(output_format) container = _m4b_container_args() if output_format == "m4b" else [] filters = atempo_filters(speed) if filters: return [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-filter_complex", f"[0:a]split=2[base][spd];[spd]{filters}[spdout]", "-map", "[base]", *encode, *container, str(output_path), "-map", "[spdout]", *encode, *container, str(speed_path), ] return [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), *encode, *container, str(output_path), ] def build_m4b_chapters_command(concat_list: Path, metadata_file: Path, output_path: Path, speed: float = 1.0, speed_path: Optional[Path] = None, speed_metadata_file: Optional[Path] = None) -> List[str]: """Build the ffmpeg command that assembles chapter audio into one m4b. Chapter metadata inputs are bound to their outputs with explicit ``-map_chapters`` so the normal-speed and speed-adjusted copies each get their own (correctly scaled) chapter markers. """ encode = _encode_args("m4b") container = _m4b_container_args() if speed_path is not None: return [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-i", str(metadata_file), "-i", str(speed_metadata_file), "-filter_complex", f"[0:a]split=2[base][spd];[spd]{atempo_filters(speed)}[spdout]", "-map", "[base]", "-map_metadata", "1", "-map_chapters", "1", *encode, *container, str(output_path), "-map", "[spdout]", "-map_metadata", "2", "-map_chapters", "2", *encode, *container, str(speed_path), ] return [ "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), "-i", str(metadata_file), "-map", "0:a", "-map_metadata", "1", "-map_chapters", "1", *encode, *container, str(output_path), ] _DURATION_WARN_TOLERANCE = 0.05 # warn when output duration drifts >5% _DURATION_FAIL_TOLERANCE = 0.25 # fail when output duration drifts >25% def verify_output_duration(path: Path, expected_ms: int) -> bool: """Sanity-check an assembled file's duration against the expected total. Catches corrupt assembly (truncated concat, bogus container metadata) before the file reaches audiobook players. Duration drift beyond the warn tolerance is logged; drift beyond the fail tolerance is an error and the output is treated as broken. Returns True when unverifiable. """ if expected_ms <= 0: return True actual_ms = probe_duration_ms(path) if actual_ms <= 0: logger.warning("Could not verify duration of %s (ffprobe failed)", path) return True drift = abs(actual_ms - expected_ms) / expected_ms if drift > _DURATION_FAIL_TOLERANCE: logger.error( "Duration mismatch for %s: expected ~%.1fs, got %.1fs (%.0f%% off); output is likely corrupt", path.name, expected_ms / 1000.0, actual_ms / 1000.0, drift * 100.0, ) return False if drift > _DURATION_WARN_TOLERANCE: logger.warning( "Duration drift for %s: expected ~%.1fs, got %.1fs (%.0f%% off)", path.name, expected_ms / 1000.0, actual_ms / 1000.0, drift * 100.0, ) 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. ``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") speed_path = None if atempo_filters(speed): speed_path = output_path.with_name(f"{output_path.stem}_{speed:g}{output_path.suffix}") cmd = build_concat_command(concat_list, output_path, output_format, speed=speed, speed_path=speed_path) proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: logger.error("ffmpeg failed: %s", proc.stderr[-2000:]) return False # 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 if not duration_ok: 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 speed_path is not None: 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 and chapter files from the scratch folder.""" try: chunk_count = 0 for pattern in ("chunk_*", "chapter_*"): for chunk_file in config.CHUNKS_FOLDER.glob(pattern): 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) 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, ) if result.returncode != 0: logger.warning("ffprobe failed for %s: %s", path, result.stderr[-200:]) return 0 try: return max(0, int(round(float(result.stdout.strip()) * 1000.0))) except ValueError: logger.warning("Could not parse ffprobe duration for %s", path) return 0 def build_ffmetadata(chapters: List[tuple], path: Path) -> None: """Write an ffmpeg FFMETADATA file with ``[CHAPTER]`` entries. ``chapters`` is a list of ``(start_ms, end_ms, title)`` tuples. """ with open(path, "w", encoding="utf-8") as meta_file: meta_file.write(";FFMETADATA1\n") for start_ms, end_ms, title in chapters: meta_file.write("[CHAPTER]\n") 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") def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str], output_path: Path, speed: float = 1.0) -> bool: """Concatenate per-chapter audio into a single m4b with embedded chapter markers. Chapter start/end times are derived from each chapter file's duration and written as ffmpeg chapter metadata. When ``speed`` differs from 1.0, a speed-adjusted copy (with rescaled chapter markers) is written alongside the normal-speed file. """ if shutil.which("ffmpeg") is None or shutil.which("ffprobe") is None: logger.error("ffmpeg and ffprobe are required to build an m4b with chapters") return False if not chapter_files: logger.error("No chapter files provided") return False concat_list = config.CHUNKS_FOLDER / "_concat_list.txt" metadata_file = config.CHUNKS_FOLDER / "_chapters.txt" speed_metadata_file = config.CHUNKS_FOLDER / "_chapters_speed.txt" try: chapters = [] start_ms = 0 with open(concat_list, "w", encoding="utf-8") as list_file: for chapter_file, title in zip(chapter_files, titles): list_file.write(f"file '{_concat_escape(str(chapter_file))}'\n") duration_ms = probe_duration_ms(chapter_file) end_ms = start_ms + duration_ms chapters.append((start_ms, end_ms, title or "Chapter")) start_ms = end_ms build_ffmetadata(chapters, metadata_file) speed_path = None if atempo_filters(speed): speed_path = output_path.with_name(f"{output_path.stem}_{speed:g}{output_path.suffix}") scaled = [(int(s / speed), int(e / speed), t) for s, e, t in chapters] build_ffmetadata(scaled, speed_metadata_file) cmd = build_m4b_chapters_command(concat_list, metadata_file, output_path, speed=speed, speed_path=speed_path, speed_metadata_file=speed_metadata_file if speed_path is not None else None) proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: logger.error("ffmpeg failed: %s", proc.stderr[-2000:]) return False # The last chapter's end time is the expected total duration. 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 not duration_ok: return False logger.info("Audiobook saved: %s (%d chapters)", output_path, len(chapter_files)) print(f"[INFO] Saved audiobook: {output_path.name} ({len(chapter_files)} chapters)") if speed_path is not None: logger.info("Saved speed-adjusted audiobook (%gx): %s", speed, speed_path) print(f"[INFO] Saved speed-adjusted audiobook: {speed_path.name} ({speed:g}x)") return True except FileNotFoundError: logger.error("ffmpeg/ffprobe not found on PATH (install ffmpeg and try again)") return False except Exception as exc: logger.error("Failed to combine chapters: %s", exc) logger.error(traceback.format_exc()) return False finally: for scratch in (concat_list, metadata_file, speed_metadata_file): try: scratch.unlink(missing_ok=True) except OSError: pass