aboutsummaryrefslogtreecommitdiff
path: root/converter/audio.py
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-08-18 01:56:25 -0400
committerhistoria <historiavg@proton.me>2026-08-18 01:56:25 -0400
commitd72d274bd9895bdf97d31d56690b7df07fa74ad4 (patch)
tree85b001881055fa1f7087de84a162a8a5a880484a /converter/audio.py
parent68ee76514a98169a5e7a075648b70ab40417fbdf (diff)
downloadtts-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.py188
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)")