diff options
| author | historia <historiavg@proton.me> | 2026-08-17 19:19:40 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-17 19:19:40 -0400 |
| commit | b80fa9db6bab6cdb2856874b606a93149cfc1af2 (patch) | |
| tree | 7404d8f0592ad225cd2074e81f3920d1a498dfe1 /converter/audio.py | |
| parent | 0ad594aa6497c4d41272e503f33fde2103b96cd6 (diff) | |
| download | tts-audiobook-generator-b80fa9db6bab6cdb2856874b606a93149cfc1af2.tar.gz | |
feat(converter): add per-chapter and m4b output
Diffstat (limited to 'converter/audio.py')
| -rw-r--r-- | converter/audio.py | 152 |
1 files changed, 140 insertions, 12 deletions
diff --git a/converter/audio.py b/converter/audio.py index e7fa0bb..2f40777 100644 --- a/converter/audio.py +++ b/converter/audio.py @@ -47,8 +47,16 @@ def _concat_escape(path: str) -> str: 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] + return ["-b:a", config.AUDIO_BITRATE] + + def combine_chunks(total_chunks: int, output_path: Path, - results: Optional[Dict[int, bool]] = None, speed: float = 1.0) -> bool: + 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 @@ -88,20 +96,21 @@ def combine_chunks(total_chunks: int, output_path: Path, list_file.write(f"file '{_concat_escape(str(chunk_file))}'\n") filters = atempo_filters(speed) + encode = _encode_args(output_format) 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), + "-map", "[base]", *encode, str(output_path), + "-map", "[spdout]", *encode, 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), + *encode, str(output_path), ] proc = subprocess.run(cmd, capture_output=True, text=True) @@ -136,19 +145,138 @@ def combine_chunks(total_chunks: int, output_path: Path, def cleanup_chunks() -> None: - """Remove temporary chunk files from the scratch folder.""" + """Remove temporary chunk and chapter 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) + 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) + + filters = atempo_filters(speed) + encode = _encode_args("m4b") + if filters: + 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) + # The speed-adjusted stream needs rescaled chapter markers, so the + # rescaled metadata is passed as a third input. + cmd = [ + "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]{filters}[spdout]", + "-map", "[base]", "-map_metadata", "1", *encode, str(output_path), + "-map", "[spdout]", "-map_metadata", "2", *encode, str(speed_path), + ] + else: + speed_path = None + cmd = [ + "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_list), + "-i", str(metadata_file), + "-map", "0:a", "-map_metadata", "1", *encode, 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 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 |
