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 | |
| parent | 68ee76514a98169a5e7a075648b70ab40417fbdf (diff) | |
| download | tts-audiobook-generator-d72d274bd9895bdf97d31d56690b7df07fa74ad4.tar.gz | |
fix: timing/punctuation edge cases, process chunks as uncompressed wavs
Diffstat (limited to 'converter')
| -rw-r--r-- | converter/audio.py | 188 | ||||
| -rw-r--r-- | converter/chunking.py | 11 | ||||
| -rw-r--r-- | converter/converter.py | 18 |
3 files changed, 172 insertions, 45 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)") diff --git a/converter/chunking.py b/converter/chunking.py index f649310..0c85adf 100644 --- a/converter/chunking.py +++ b/converter/chunking.py @@ -11,8 +11,10 @@ def split_into_chunks(text: str, max_words: int = config.CHUNK_SIZE_WORDS) -> Li Splits on sentence boundaries. Sentences longer than the limit are split further at clause punctuation (which is kept attached for TTS prosody). - A single sentence with no clause punctuation longer than the limit is - kept intact as one oversized chunk. + Clause splits only happen at whitespace after punctuation, so tokens like + "1,000,000" or "12:30" are never broken apart or re-joined with added + spaces. A single sentence with no usable split point longer than the + limit is kept intact as one oversized chunk. """ if not text.strip(): return [] @@ -32,7 +34,10 @@ def split_into_chunks(text: str, max_words: int = config.CHUNK_SIZE_WORDS) -> Li current_words = 0 # Split long sentences at clause boundaries, keeping punctuation. - parts = re.split(r"(?<=[,;:])\s*", sentence) + # Only split where whitespace already follows the punctuation so + # the reassembled text is byte-identical to the input (no spaces + # injected into "1,000,000" or "12:30"). + parts = re.split(r"(?<=[,;:])\s+", sentence) for part in parts: part_words = len(part.split()) if current_words + part_words <= max_words: diff --git a/converter/converter.py b/converter/converter.py index f63a412..0454892 100644 --- a/converter/converter.py +++ b/converter/converter.py @@ -130,12 +130,17 @@ class AudiobookConverter: def _convert_m4b_with_chapters(self, sections, stem: str, start_time: float) -> bool: """Convert each chapter to audio, then assemble a single m4b with - embedded chapter markers.""" + embedded chapter markers. + + Chapters are synthesized to lossless WAV scratch files (~170 MB per + hour of audio) so the final AAC pass is the only lossy encode. + """ chapter_files = [] titles = [] for index, section in enumerate(sections, 1): - chapter_path = config.CHUNKS_FOLDER / f"chapter_{index:04d}.{self.output_format}" - if not self._convert_text(section.text, chapter_path, start_time, speed=1.0): + chapter_path = config.CHUNKS_FOLDER / f"chapter_{index:04d}.wav" + if not self._convert_text(section.text, chapter_path, start_time, + speed=1.0, output_format="wav"): logger.warning("Skipping chapter %d (%s) due to conversion failure", index, section.title) continue @@ -150,10 +155,13 @@ class AudiobookConverter: return audio.combine_chapters_to_m4b(chapter_files, titles, output_path, speed=self.speed) def _convert_text(self, text: str, output_path: Path, start_time: float, - speed: Optional[float] = None) -> bool: + speed: Optional[float] = None, + output_format: Optional[str] = None) -> bool: """Chunk, synthesize, and assemble ``text`` into ``output_path``.""" if speed is None: speed = self.speed + if output_format is None: + output_format = self.output_format try: if not text.strip(): @@ -217,7 +225,7 @@ class AudiobookConverter: # Combine chunks (only the successful ones) success = audio.combine_chunks(total_chunks, output_path, results, - speed=speed, output_format=self.output_format) + speed=speed, output_format=output_format) if success: duration = time.time() - start_time |
