diff options
| author | historia <historiavg@proton.me> | 2026-08-18 01:56:25 -0400 |
|---|---|---|
| committer | historia <historiavg@proton.me> | 2026-08-18 01:56:25 -0400 |
| commit | d72d274bd9895bdf97d31d56690b7df07fa74ad4 (patch) | |
| tree | 85b001881055fa1f7087de84a162a8a5a880484a /converter/audio.py | |
| parent | 68ee76514a98169a5e7a075648b70ab40417fbdf (diff) | |
| download | tts-audiobook-generator-d72d274bd9895bdf97d31d56690b7df07fa74ad4.tar.gz | |
fix: timing/punctuation edge cases, process chunks as uncompressed wavs
Diffstat (limited to 'converter/audio.py')
| -rw-r--r-- | converter/audio.py | 188 |
1 files changed, 151 insertions, 37 deletions
diff --git a/converter/audio.py b/converter/audio.py index 2f40777..9912004 100644 --- a/converter/audio.py +++ b/converter/audio.py @@ -51,9 +51,132 @@ 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: @@ -95,33 +218,30 @@ def combine_chunks(total_chunks: int, output_path: Path, for chunk_file in chunk_files: list_file.write(f"file '{_concat_escape(str(chunk_file))}'\n") - filters = atempo_filters(speed) - encode = _encode_args(output_format) - if filters: + speed_path = None + if atempo_filters(speed): 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]", *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), - *encode, str(output_path), - ] + 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 filters: + 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)") @@ -229,35 +349,29 @@ def combine_chapters_to_m4b(chapter_files: List[Path], titles: List[str], build_ffmetadata(chapters, metadata_file) - filters = atempo_filters(speed) - encode = _encode_args("m4b") - if filters: + 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) - # 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), - ] + 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)") |
