mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Merge pull request #13 from denizsafak/beta
- Added new output format: `m4b`, enabling chapter metadata in audiobooks. Special thanks to @jborza for implementing this feature in PR #10. - Better approach for determining the correct configuration folder for Linux and MacOS, using platformdirs. (Fixes Docker issue #12) - Improvements in documentation and code.
This commit is contained in:
+3
-1
@@ -1 +1,3 @@
|
|||||||
- Better approach for detemining the correct configuration folder for Linux and MacOS, using platformdirs.
|
- Added new output format: `m4b`, enabling chapter metadata in audiobooks. Special thanks to @jborza for implementing this feature in PR #10.
|
||||||
|
- Better approach for determining the correct configuration folder for Linux and MacOS, using platformdirs. (Fixes Docker issue #12)
|
||||||
|
- Improvements in documentation and code.
|
||||||
@@ -85,7 +85,7 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex
|
|||||||
- **Voices**: First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female.
|
- **Voices**: First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female.
|
||||||
- **Voice mixer**: Create custom voices by mixing different voice models with a profile system.
|
- **Voice mixer**: Create custom voices by mixing different voice models with a profile system.
|
||||||
- **Generate subtitles**: `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry)
|
- **Generate subtitles**: `Disabled`, `Sentence`, `Sentence + Comma`, `1 word`, `2 words`, `3 words`, etc. (Represents the number of words in each subtitle entry)
|
||||||
- **Output formats**: `.WAV`, `.FLAC`, or `.MP3`
|
- **Output formats**: `.WAV`, `.FLAC`, `.MP3`, and `M4B (with chapters)` (Special thanks to @jborza for chapter support in PR #10)
|
||||||
- **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder`
|
- **Save location**: `Save next to input file`, `Save to desktop`, or `Choose output folder`
|
||||||
- **Chapter Control**: Select specific `chapters` from ePUBs or `chapters + pages` from PDFs.
|
- **Chapter Control**: Select specific `chapters` from ePUBs or `chapters + pages` from PDFs.
|
||||||
- **Options**:
|
- **Options**:
|
||||||
@@ -185,6 +185,10 @@ Abogen launches automatically inside the container.
|
|||||||
- You can use `/shared` directory to share files between your host and the container.
|
- You can use `/shared` directory to share files between your host and the container.
|
||||||
- For later use, start it with `docker start abogen` and stop it with `docker stop abogen`.
|
- For later use, start it with `docker start abogen` and stop it with `docker stop abogen`.
|
||||||
|
|
||||||
|
Known issues:
|
||||||
|
- Audio preview is not working inside container (ALSA error).
|
||||||
|
- `Open temp directory` and `Open configuration directory` options in settings not working. (Tried pcmanfm, did not work with abogen).
|
||||||
|
|
||||||
(Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/).)
|
(Special thanks to [@geo38](https://www.reddit.com/user/geo38/) from Reddit, who provided the Dockerfile and instructions in [this comment](https://www.reddit.com/r/selfhosted/comments/1k8x1yo/comment/mpe0bz8/).)
|
||||||
|
|
||||||
## `Similar Projects`
|
## `Similar Projects`
|
||||||
@@ -196,9 +200,9 @@ Abogen is a standalone project, but it is inspired by and shares some similariti
|
|||||||
|
|
||||||
## `Roadmap`
|
## `Roadmap`
|
||||||
- [ ] Improve PDF support for better text extraction.
|
- [ ] Improve PDF support for better text extraction.
|
||||||
- [ ] Add chapter metadata for .m4a files using ffmpeg-bin.
|
- [x] Add chapter metadata for .m4a files. (Issue #9, PR #10)
|
||||||
- [ ] Add support for different languages in GUI.
|
- [ ] Add support for different languages in GUI.
|
||||||
- [x] Add voice formula feature that enables mixing different voice models. #1
|
- [x] Add voice formula feature that enables mixing different voice models. (Issue #1, PR #5)
|
||||||
- [ ] Add support for kokoro-onnx.
|
- [ ] Add support for kokoro-onnx.
|
||||||
- [ ] Add dark mode.
|
- [ ] Add dark mode.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -221,7 +221,7 @@ if errorlevel 1 (
|
|||||||
|
|
||||||
:: Install setup requirements
|
:: Install setup requirements
|
||||||
echo Installing setup requirements...
|
echo Installing setup requirements...
|
||||||
%PYTHON_CONSOLE_PATH% -m pip install --upgrade setuptools wheel sphinx hatchling --no-warn-script-location
|
%PYTHON_CONSOLE_PATH% -m pip install --upgrade setuptools wheel sphinx hatchling progress --no-warn-script-location
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo Failed to install setup requirements.
|
echo Failed to install setup requirements.
|
||||||
pause
|
pause
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.0.4
|
1.0.5
|
||||||
+87
-11
@@ -11,7 +11,8 @@ import soundfile as sf
|
|||||||
from utils import clean_text
|
from utils import clean_text
|
||||||
from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS
|
from constants import PROGRAM_NAME, LANGUAGE_DESCRIPTIONS, SAMPLE_VOICE_TEXTS
|
||||||
from voice_formulas import get_new_voice
|
from voice_formulas import get_new_voice
|
||||||
|
import static_ffmpeg
|
||||||
|
import threading # for efficient waiting
|
||||||
|
|
||||||
def get_sample_voice_text(lang_code):
|
def get_sample_voice_text(lang_code):
|
||||||
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
|
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
|
||||||
@@ -128,6 +129,7 @@ class ConversionThread(QThread):
|
|||||||
use_gpu=True,
|
use_gpu=True,
|
||||||
): # Add use_gpu parameter
|
): # Add use_gpu parameter
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self._chapter_options_event = threading.Event()
|
||||||
self.np = np_module
|
self.np = np_module
|
||||||
self.KPipeline = kpipeline_class
|
self.KPipeline = kpipeline_class
|
||||||
self.file_name = file_name
|
self.file_name = file_name
|
||||||
@@ -252,18 +254,12 @@ class ConversionThread(QThread):
|
|||||||
and not self.chapter_options_set
|
and not self.chapter_options_set
|
||||||
):
|
):
|
||||||
|
|
||||||
self.waiting_for_user_input = True
|
# Emit signal to main thread and wait
|
||||||
# Emit signal to main thread to show dialog
|
|
||||||
self.chapters_detected.emit(total_chapters)
|
self.chapters_detected.emit(total_chapters)
|
||||||
|
self._chapter_options_event.wait()
|
||||||
# Wait for chapter options to be set
|
|
||||||
while self.waiting_for_user_input and not self.cancel_requested:
|
|
||||||
time.sleep(0.1)
|
|
||||||
|
|
||||||
if self.cancel_requested:
|
if self.cancel_requested:
|
||||||
self.conversion_finished.emit("Cancelled", None)
|
self.conversion_finished.emit("Cancelled", None)
|
||||||
return
|
return
|
||||||
|
|
||||||
self.chapter_options_set = True
|
self.chapter_options_set = True
|
||||||
|
|
||||||
# Log all detected chapters at the beginning
|
# Log all detected chapters at the beginning
|
||||||
@@ -339,6 +335,12 @@ class ConversionThread(QThread):
|
|||||||
# Initialize current segment counter
|
# Initialize current segment counter
|
||||||
current_segment = 0
|
current_segment = 0
|
||||||
|
|
||||||
|
# Initialize chapter times
|
||||||
|
chapters_time = [
|
||||||
|
{"chapter": chapter[0], "start": 0.0, "end": 0.0}
|
||||||
|
for chapter in chapters
|
||||||
|
]
|
||||||
|
|
||||||
# Instead of processing the whole text, process by chapter
|
# Instead of processing the whole text, process by chapter
|
||||||
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
|
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
|
||||||
if total_chapters > 1:
|
if total_chapters > 1:
|
||||||
@@ -354,6 +356,10 @@ class ConversionThread(QThread):
|
|||||||
chapter_subtitle_entries = []
|
chapter_subtitle_entries = []
|
||||||
chapter_current_time = 0.0
|
chapter_current_time = 0.0
|
||||||
|
|
||||||
|
# chapter start time
|
||||||
|
chapter_time = chapters_time[chapter_idx - 1]
|
||||||
|
chapter_time["start"] = current_time
|
||||||
|
|
||||||
# Set split_pattern to \n+ which will split on one or more newlines
|
# Set split_pattern to \n+ which will split on one or more newlines
|
||||||
split_pattern = r"\n+"
|
split_pattern = r"\n+"
|
||||||
|
|
||||||
@@ -467,6 +473,9 @@ class ConversionThread(QThread):
|
|||||||
# Update progress more frequently (after each result)
|
# Update progress more frequently (after each result)
|
||||||
self.progress_updated.emit(percent, etr_str)
|
self.progress_updated.emit(percent, etr_str)
|
||||||
|
|
||||||
|
# Update chapter end time
|
||||||
|
chapter_time["end"] = current_time
|
||||||
|
|
||||||
# Save the individual chapter output if save_chapters_separately is enabled
|
# Save the individual chapter output if save_chapters_separately is enabled
|
||||||
if (
|
if (
|
||||||
save_chapters_separately
|
save_chapters_separately
|
||||||
@@ -483,14 +492,20 @@ class ConversionThread(QThread):
|
|||||||
|
|
||||||
# Concatenate chapter audio and save
|
# Concatenate chapter audio and save
|
||||||
chapter_audio = self.np.concatenate(chapter_audio_segments)
|
chapter_audio = self.np.concatenate(chapter_audio_segments)
|
||||||
|
# If output format is m4b, save chapter as wav
|
||||||
|
chapter_ext = self.output_format
|
||||||
|
chapter_format = self.output_format
|
||||||
|
if self.output_format == "m4b":
|
||||||
|
chapter_ext = "wav"
|
||||||
|
chapter_format = "wav"
|
||||||
chapter_out_path = os.path.join(
|
chapter_out_path = os.path.join(
|
||||||
chapters_out_dir, f"{chapter_filename}.{self.output_format}"
|
chapters_out_dir, f"{chapter_filename}.{chapter_ext}"
|
||||||
)
|
)
|
||||||
sf.write(
|
sf.write(
|
||||||
chapter_out_path,
|
chapter_out_path,
|
||||||
chapter_audio,
|
chapter_audio,
|
||||||
24000,
|
24000,
|
||||||
format=self.output_format,
|
format=chapter_format,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate .srt subtitle file for chapter if not Disabled
|
# Generate .srt subtitle file for chapter if not Disabled
|
||||||
@@ -531,6 +546,7 @@ class ConversionThread(QThread):
|
|||||||
or not self.save_chapters_separately
|
or not self.save_chapters_separately
|
||||||
or getattr(self, "merge_chapters_at_end", True)
|
or getattr(self, "merge_chapters_at_end", True)
|
||||||
)
|
)
|
||||||
|
m4b_picked = False # Ensure m4b_picked is always defined
|
||||||
if audio_segments and merge_chapters:
|
if audio_segments and merge_chapters:
|
||||||
self.log_updated.emit("\nFinalizing audio file...\n")
|
self.log_updated.emit("\nFinalizing audio file...\n")
|
||||||
audio = self.np.concatenate(audio_segments)
|
audio = self.np.concatenate(audio_segments)
|
||||||
@@ -539,7 +555,16 @@ class ConversionThread(QThread):
|
|||||||
out_filename = f"{base_name}{suffix}.{self.output_format}"
|
out_filename = f"{base_name}{suffix}.{self.output_format}"
|
||||||
out_path = os.path.join(out_dir, out_filename)
|
out_path = os.path.join(out_dir, out_filename)
|
||||||
srt_path = os.path.splitext(out_path)[0] + ".srt"
|
srt_path = os.path.splitext(out_path)[0] + ".srt"
|
||||||
|
|
||||||
|
# in case the user picked m4b format, we need to change the output format to wav
|
||||||
|
if self.output_format == "m4b":
|
||||||
|
out_path = os.path.splitext(out_path)[0] + ".wav"
|
||||||
|
self.output_format = "wav"
|
||||||
|
m4b_picked = True
|
||||||
sf.write(out_path, audio, 24000, format=self.output_format)
|
sf.write(out_path, audio, 24000, format=self.output_format)
|
||||||
|
if m4b_picked:
|
||||||
|
out_path = self._generate_m4b_with_chapters(out_path, chapters_time)
|
||||||
|
|
||||||
if self.subtitle_mode != "Disabled":
|
if self.subtitle_mode != "Disabled":
|
||||||
with open(
|
with open(
|
||||||
srt_path, "w", encoding="utf-8", errors="replace"
|
srt_path, "w", encoding="utf-8", errors="replace"
|
||||||
@@ -579,6 +604,57 @@ class ConversionThread(QThread):
|
|||||||
self.save_chapters_separately = options["save_chapters_separately"]
|
self.save_chapters_separately = options["save_chapters_separately"]
|
||||||
self.merge_chapters_at_end = options["merge_chapters_at_end"]
|
self.merge_chapters_at_end = options["merge_chapters_at_end"]
|
||||||
self.waiting_for_user_input = False
|
self.waiting_for_user_input = False
|
||||||
|
self._chapter_options_event.set()
|
||||||
|
|
||||||
|
def _generate_m4b_with_chapters(self, out_path, chapters_time):
|
||||||
|
# If there is only one chapter, skip adding chapters and just return the wav file path
|
||||||
|
if not chapters_time or len(chapters_time) <= 1:
|
||||||
|
self.log_updated.emit(
|
||||||
|
("File contains only one chapter or no chapters were detected. The audio will be saved as a standard .wav file instead.\n", "red")
|
||||||
|
)
|
||||||
|
return out_path
|
||||||
|
# generate chapters.txt in the same folder as the output file
|
||||||
|
chapters_info_path = os.path.splitext(out_path)[0] + "_chapters.txt"
|
||||||
|
with open(chapters_info_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(';FFMETADATA1\n') # required header for ffmpeg
|
||||||
|
for chapter in chapters_time:
|
||||||
|
f.write(f"[CHAPTER]\n")
|
||||||
|
f.write(f"TIMEBASE=1/10\n")
|
||||||
|
# use 10th of second for start/end time
|
||||||
|
f.write(f"START={int(chapter["start"]*10)}\n")
|
||||||
|
f.write(f"END={int(chapter["end"]*10)}\n")
|
||||||
|
f.write(f"title={chapter["chapter"]}\n\n")
|
||||||
|
# call ffmpeg to merge the chapters into the output file
|
||||||
|
# ffmpeg installed on first call to add_paths()
|
||||||
|
static_ffmpeg.add_paths()
|
||||||
|
import subprocess
|
||||||
|
out_path_m4b = os.path.splitext(out_path)[0] + ".m4b"
|
||||||
|
# ffmpeg -i input.m4b -i ch.txt -map 0:a -map_chapters 1 output.m4b
|
||||||
|
ffmpeg_cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-i", out_path,
|
||||||
|
"-i", chapters_info_path,
|
||||||
|
"-map", "0:a",
|
||||||
|
"-map_chapters", "1",
|
||||||
|
out_path_m4b
|
||||||
|
]
|
||||||
|
|
||||||
|
self.log_updated.emit("Adding chapters to the audio file...\n")
|
||||||
|
result = subprocess.run(ffmpeg_cmd, capture_output=True, text=True)
|
||||||
|
# Check for errors in the ffmpeg command
|
||||||
|
if result.returncode != 0:
|
||||||
|
self.log_updated.emit((f"FFmpeg error: {result.stderr}", "red"))
|
||||||
|
# Log error but continue with original file
|
||||||
|
self.log_updated.emit(("Falling back to the original audio file without chapters\n", "orange"))
|
||||||
|
return out_path
|
||||||
|
else:
|
||||||
|
self.log_updated.emit(("Successfully added chapters to the audio file.\n", "green"))
|
||||||
|
|
||||||
|
# delete the chapters path and original (wav) file
|
||||||
|
os.remove(chapters_info_path)
|
||||||
|
os.remove(out_path)
|
||||||
|
# the new file is in m4b format - use that for messages
|
||||||
|
return out_path_m4b
|
||||||
|
|
||||||
def _srt_time(self, t):
|
def _srt_time(self, t):
|
||||||
"""Helper function to format time for SRT files"""
|
"""Helper function to format time for SRT files"""
|
||||||
|
|||||||
+11
-6
@@ -670,13 +670,18 @@ class abogen(QWidget):
|
|||||||
format_layout = QVBoxLayout()
|
format_layout = QVBoxLayout()
|
||||||
format_layout.addWidget(QLabel("Output Format:", self))
|
format_layout.addWidget(QLabel("Output Format:", self))
|
||||||
self.format_combo = QComboBox(self)
|
self.format_combo = QComboBox(self)
|
||||||
self.format_combo.setStyleSheet(
|
self.format_combo.setStyleSheet("QComboBox { min-height: 20px; padding: 6px 12px; }")
|
||||||
"QComboBox { min-height: 20px; padding: 6px 12px; }"
|
# Add items with display labels and underlying keys
|
||||||
|
for key, label in [("wav","wav"),("flac","flac"),("mp3","mp3"),("m4b","m4b (with chapters)")]:
|
||||||
|
self.format_combo.addItem(label, key)
|
||||||
|
# Initialize selection by matching saved key
|
||||||
|
idx = self.format_combo.findData(self.selected_format)
|
||||||
|
if idx >= 0:
|
||||||
|
self.format_combo.setCurrentIndex(idx)
|
||||||
|
# Map selection back to key on change
|
||||||
|
self.format_combo.currentIndexChanged.connect(
|
||||||
|
lambda i: self.on_format_changed(self.format_combo.itemData(i))
|
||||||
)
|
)
|
||||||
format_options = ["wav", "flac", "mp3"]
|
|
||||||
self.format_combo.addItems(format_options)
|
|
||||||
self.format_combo.setCurrentText(self.selected_format)
|
|
||||||
self.format_combo.currentTextChanged.connect(self.on_format_changed)
|
|
||||||
format_layout.addWidget(self.format_combo)
|
format_layout.addWidget(self.format_combo)
|
||||||
controls_layout.addLayout(format_layout)
|
controls_layout.addLayout(format_layout)
|
||||||
# Save location
|
# Save location
|
||||||
|
|||||||
+2
-1
@@ -22,7 +22,8 @@ dependencies = [
|
|||||||
"soundfile>=0.13.1",
|
"soundfile>=0.13.1",
|
||||||
"pygame>=2.6.1",
|
"pygame>=2.6.1",
|
||||||
"charset_normalizer>=3.4.1",
|
"charset_normalizer>=3.4.1",
|
||||||
"chardet>=5.2.0"
|
"chardet>=5.2.0",
|
||||||
|
"static_ffmpeg>=2.13"
|
||||||
]
|
]
|
||||||
|
|
||||||
classifiers = [
|
classifiers = [
|
||||||
|
|||||||
Reference in New Issue
Block a user