Add support for embedding cover images in M4B files and update documentation

This commit is contained in:
Deniz Şafak
2025-11-19 01:03:10 +03:00
parent c72c8e777c
commit 10d3f470e7
5 changed files with 116 additions and 23 deletions
+1
View File
@@ -4,6 +4,7 @@
- New option: **"Subtitle speed adjustment method"**: Choose how to speed up audio when needed:
- **TTS Regeneration (better quality):** Re-generates the audio at a faster speed for more natural sound.
- **FFmpeg Time-stretch (better speed):** Quickly speeds up the generated audio.
- Added support for embedding cover images in M4B files. Abogen now automatically extracts cover images from EPUB and PDF files. You can also manually specify a cover image using the `<<METADATA_COVER_PATH:path>>` tag in your text file.
- Fixed `[WinError 1114] A dynamic link library (DLL) initialization routine failed` error on Windows, pre-loading PyTorch DLLs before initializing PyQt6 to avoid DLL initialization errors, mentioned in #98 by @ephr0n.
- Potential fix for `CUDA GPU is not available` issue, by ensuring PyTorch is installed correctly with CUDA support on Windows using the installer script.
- Improvements in code and documentation.
+3
View File
@@ -230,7 +230,9 @@ Similar to chapter markers, it is possible to add metadata tags for `M4B` files.
<<METADATA_ALBUM_ARTIST:Album Artist>>
<<METADATA_COMPOSER:Narrator>>
<<METADATA_GENRE:Audiobook>>
<<METADATA_COVER_PATH:path/to/cover.jpg>>
```
> Note: `METADATA_COVER_PATH` is used to embed a cover image into the generated M4B file. Abogen automatically extracts the cover from EPUB and PDF files and adds this tag for you.
## `About Timestamp-based Text Files`
Similar to converting subtitle files to audio, Abogen can automatically detect text files that contain timestamps in `HH:MM:SS` or `HH:MM:SS,ms` format. When timestamps are found inside your text file, Abogen will ask if you want to use them for audio timing. This is useful for creating timed narrations, scripts, or transcripts where you need exact control over when each segment is spoken.
@@ -274,6 +276,7 @@ I highly recommend using [MPV](https://mpv.io/installation/) to play your audio
# --- MPV Settings ---
save-position-on-quit
keep-open=yes
audio-display=no
# --- Subtitle ---
sub-ass-override=no
sub-margin-y=50
+21
View File
@@ -89,6 +89,9 @@ class HandlerDialog(QDialog):
def __init__(self, book_path, file_type=None, checked_chapters=None, parent=None):
super().__init__(parent)
# Normalize path
book_path = os.path.normpath(os.path.abspath(book_path))
# Determine file type if not explicitly provided
if file_type:
self.file_type = file_type
@@ -2211,6 +2214,7 @@ class HandlerDialog(QDialog):
def _format_metadata_tags(self):
"""Format metadata tags for insertion at the beginning of the text"""
import datetime
from abogen.utils import get_user_cache_path
metadata = self.book_metadata
filename = os.path.splitext(os.path.basename(self.book_path))[0]
@@ -2231,6 +2235,20 @@ class HandlerDialog(QDialog):
f"{total_chapters} {'Chapters' if self.file_type == 'epub' else 'Pages'}"
)
# Handle cover image
cover_tag = ""
if metadata.get("cover_image"):
try:
import uuid
cache_dir = get_user_cache_path()
cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg")
cover_path = os.path.normpath(cover_path)
with open(cover_path, "wb") as f:
f.write(metadata["cover_image"])
cover_tag = f"<<METADATA_COVER_PATH:{cover_path}>>"
except Exception as e:
logging.warning(f"Failed to save cover image: {e}")
# Format metadata tags
metadata_tags = [
f"<<METADATA_TITLE:{title}>>",
@@ -2242,6 +2260,9 @@ class HandlerDialog(QDialog):
f"<<METADATA_GENRE:Audiobook>>",
]
if cover_tag:
metadata_tags.append(cover_tag)
return "\n".join(metadata_tags)
def _get_markdown_selected_text(self):
+87 -19
View File
@@ -1000,7 +1000,7 @@ class ConversionThread(QThread):
static_ffmpeg.add_paths()
merged_out_file = None
ffmpeg_proc = None
metadata_options = (
metadata_options, cover_path = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
)
# Prepare ffmpeg command for m4b output
@@ -1017,13 +1017,32 @@ class ConversionThread(QThread):
"1",
"-i",
"pipe:0",
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
]
if cover_path and os.path.exists(cover_path):
cmd.extend(
[
"-i",
cover_path,
"-map",
"0:a",
"-map",
"1",
"-c:v",
"copy",
"-disposition:v",
"attached_pic",
]
)
cmd.extend(
[
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
]
)
cmd += metadata_options
cmd.append(merged_out_path)
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
@@ -1529,7 +1548,7 @@ class ConversionThread(QThread):
orig_path = merged_out_path
root, ext = os.path.splitext(orig_path)
tmp_path = root + ".tmp" + ext
metadata_options = (
metadata_options, cover_path = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
)
cmd = [
@@ -1539,15 +1558,35 @@ class ConversionThread(QThread):
orig_path,
"-i",
chapters_info_path,
"-map",
"0:a",
"-map_metadata",
"1",
"-map_chapters",
"1",
"-c:a",
"copy",
]
if cover_path and os.path.exists(cover_path):
cmd.extend(
[
"-i",
cover_path,
"-map",
"0:a",
"-map",
"2",
"-c:v",
"copy",
"-disposition:v",
"attached_pic",
]
)
else:
cmd.extend(["-map", "0:a"])
cmd.extend(
[
"-map_metadata",
"1",
"-map_chapters",
"1",
"-c:a",
"copy",
]
)
cmd += metadata_options
cmd.append(tmp_path)
proc = create_process(cmd)
@@ -1659,8 +1698,35 @@ class ConversionThread(QThread):
static_ffmpeg.add_paths()
cmd = ["ffmpeg", "-y", "-thread_queue_size", "32768", "-f", "f32le", "-ar", str(rate), "-ac", "1", "-i", "pipe:0"]
if self.output_format == "m4b":
cmd.extend(["-c:a", "aac", "-q:a", "2", "-movflags", "+faststart+use_metadata_tags"])
cmd.extend(self._extract_and_add_metadata_tags_to_ffmpeg_cmd())
metadata_options, cover_path = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
)
if cover_path and os.path.exists(cover_path):
cmd.extend(
[
"-i",
cover_path,
"-map",
"0:a",
"-map",
"1",
"-c:v",
"copy",
"-disposition:v",
"attached_pic",
]
)
cmd.extend(
[
"-c:a",
"aac",
"-q:a",
"2",
"-movflags",
"+faststart+use_metadata_tags",
]
)
cmd.extend(metadata_options)
elif self.output_format == "opus":
cmd.extend(["-c:a", "libopus", "-b:a", "24000"])
else:
@@ -1901,6 +1967,8 @@ class ConversionThread(QThread):
album_artist_match = re.search(r"<<METADATA_ALBUM_ARTIST:([^>]*)>>", text)
composer_match = re.search(r"<<METADATA_COMPOSER:([^>]*)>>", text)
genre_match = re.search(r"<<METADATA_GENRE:([^>]*)>>", text)
cover_match = re.search(r"<<METADATA_COVER_PATH:([^>]*)>>", text)
cover_path = cover_match.group(1) if cover_match else None
# Use display path or filename as fallback for title
@@ -1964,7 +2032,7 @@ class ConversionThread(QThread):
metadata_options.extend(["-metadata", f"genre=Audiobook"])
# Add these to ffmpeg command
return metadata_options
return metadata_options, cover_path
def _srt_time(self, t):
"""Helper function to format time for SRT files"""
+4 -4
View File
@@ -3635,9 +3635,9 @@ Categories=AudioVideo;Audio;Utility;
# Get the abogen cache directory
cache_dir = get_user_cache_path()
# Find all .txt files in the abogen cache directory
pattern = os.path.join(cache_dir, "*.txt")
cache_files = glob.glob(pattern)
# Find all .txt files and cover images in the abogen cache directory
cache_files = glob.glob(os.path.join(cache_dir, "*.txt"))
cache_files.extend(glob.glob(os.path.join(cache_dir, "cover_*.jpg")))
# Count the files
file_count = len(cache_files)
@@ -3669,7 +3669,7 @@ Categories=AudioVideo;Audio;Utility;
msg_box.setText(msg_text + "\nDo you want to delete them?")
# Add checkbox for preview cache
preview_cache_checkbox = QCheckBox("Clean preview cache", msg_box)
preview_cache_checkbox = QCheckBox("Also clean preview cache", msg_box)
preview_cache_checkbox.setChecked(False)
# Only enable checkbox if preview files exist
preview_cache_checkbox.setEnabled(preview_count > 0)