mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Add chapter metadata for .m4a files using ffmpeg.
|
||||
@@ -0,0 +1 @@
|
||||
1.0.0
|
||||
@@ -0,0 +1,9 @@
|
||||
from abogen.utils import get_version
|
||||
|
||||
# Program Information
|
||||
PROGRAM_NAME = "abogen"
|
||||
PROGRAM_DESCRIPTION = (
|
||||
"Generate audiobooks from EPUBs, PDFs and text with synchronized captions."
|
||||
)
|
||||
GITHUB_URL = "https://github.com/denizsafak/abogen"
|
||||
VERSION = get_version()
|
||||
@@ -0,0 +1,94 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "target=%~dp0..\..\python_embedded\Scripts\abogen.exe"
|
||||
set "icon=%~dp0icon.ico"
|
||||
set "shortcut=%USERPROFILE%\Desktop\abogen.lnk"
|
||||
set "shortcutParent=%~dp0..\..\abogen.lnk"
|
||||
|
||||
set "create_desktop_shortcut=true"
|
||||
|
||||
:parse_args
|
||||
if "%~1"=="" goto continue
|
||||
if /i "%~1"=="--no-create-desktop-shortcut" (
|
||||
set "create_desktop_shortcut=false"
|
||||
) else if /i "%~1"=="true" (
|
||||
set "create_desktop_shortcut=true"
|
||||
) else if /i "%~1"=="false" (
|
||||
set "create_desktop_shortcut=false"
|
||||
)
|
||||
shift
|
||||
goto parse_args
|
||||
|
||||
:continue
|
||||
if /i "%create_desktop_shortcut%"=="true" (
|
||||
echo Creating desktop shortcut...
|
||||
:: Try PowerShell method
|
||||
powershell -NoProfile -Command ^
|
||||
"$s = New-Object -ComObject WScript.Shell; " ^
|
||||
"$sc = $s.CreateShortcut('%shortcut%'); " ^
|
||||
"$sc.TargetPath = '%target%'; " ^
|
||||
"$sc.IconLocation = '%icon%'; " ^
|
||||
"$sc.Save()"
|
||||
if errorlevel 1 (
|
||||
echo PowerShell method failed. Trying another method...
|
||||
goto vbscript
|
||||
) else (
|
||||
echo Shortcut created successfully.
|
||||
goto createParent
|
||||
)
|
||||
|
||||
:vbscript
|
||||
echo Creating desktop shortcut...
|
||||
echo Set oWS = WScript.CreateObject("WScript.Shell") > "%temp%\create_shortcut.vbs"
|
||||
echo Set oLink = oWS.CreateShortcut("%shortcut%") >> "%temp%\create_shortcut.vbs"
|
||||
echo oLink.TargetPath = "%target%" >> "%temp%\create_shortcut.vbs"
|
||||
echo oLink.IconLocation = "%icon%" >> "%temp%\create_shortcut.vbs"
|
||||
echo oLink.Save >> "%temp%\create_shortcut.vbs"
|
||||
cscript //nologo "%temp%\create_shortcut.vbs"
|
||||
del "%temp%\create_shortcut.vbs"
|
||||
|
||||
if exist "%shortcut%" (
|
||||
echo Shortcut created successfully.
|
||||
) else (
|
||||
echo Failed to create shortcut.
|
||||
)
|
||||
) else (
|
||||
echo Desktop shortcut creation skipped.
|
||||
)
|
||||
|
||||
:createParent
|
||||
echo Creating shortcut in parent parent folder...
|
||||
:: Try PowerShell method
|
||||
powershell -NoProfile -Command ^
|
||||
"$s = New-Object -ComObject WScript.Shell; " ^
|
||||
"$sc = $s.CreateShortcut('%shortcutParent%'); " ^
|
||||
"$sc.TargetPath = '%target%'; " ^
|
||||
"$sc.IconLocation = '%icon%'; " ^
|
||||
"$sc.Save()"
|
||||
if errorlevel 1 (
|
||||
echo PowerShell method failed. Trying another method...
|
||||
goto vbscriptParent
|
||||
) else (
|
||||
echo Shortcut created successfully.
|
||||
goto end
|
||||
)
|
||||
|
||||
:vbscriptParent
|
||||
echo Creating shortcut in parent parent folder...
|
||||
echo Set oWS = WScript.CreateObject("WScript.Shell") > "%temp%\create_shortcut_parent.vbs"
|
||||
echo Set oLink = oWS.CreateShortcut("%shortcutParent%") >> "%temp%\create_shortcut_parent.vbs"
|
||||
echo oLink.TargetPath = "%target%" >> "%temp%\create_shortcut_parent.vbs"
|
||||
echo oLink.IconLocation = "%icon%" >> "%temp%\create_shortcut_parent.vbs"
|
||||
echo oLink.Save >> "%temp%\create_shortcut_parent.vbs"
|
||||
cscript //nologo "%temp%\create_shortcut_parent.vbs"
|
||||
del "%temp%\create_shortcut_parent.vbs"
|
||||
|
||||
if exist "%shortcutParent%" (
|
||||
echo Shortcut created successfully.
|
||||
) else (
|
||||
echo Failed to create shortcut.
|
||||
)
|
||||
|
||||
:end
|
||||
echo.
|
||||
exit /b
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 585 B |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,700 @@
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from PyQt5.QtCore import QThread, pyqtSignal, Qt
|
||||
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QDialog, QLabel, QDialogButtonBox
|
||||
import soundfile as sf
|
||||
from abogen.utils import clean_text, SAMPLE_VOICE_TEXTS, LANGUAGE_DESCRIPTIONS
|
||||
from abogen import PROGRAM_NAME
|
||||
|
||||
|
||||
def get_sample_voice_text(lang_code):
|
||||
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
|
||||
|
||||
|
||||
class ChapterOptionsDialog(QDialog):
|
||||
def __init__(self, chapter_count, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Chapter Options")
|
||||
self.setMinimumWidth(350)
|
||||
# Prevent closing with the X button and remove the help button
|
||||
self.setWindowFlags(
|
||||
self.windowFlags()
|
||||
& ~Qt.WindowCloseButtonHint
|
||||
& ~Qt.WindowContextHelpButtonHint
|
||||
)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Add informational label
|
||||
layout.addWidget(QLabel(f"Detected {chapter_count} chapters in the text file."))
|
||||
layout.addWidget(QLabel("How would you like to process these chapters?"))
|
||||
|
||||
# Add checkboxes
|
||||
self.save_separately_checkbox = QCheckBox("Save each chapter separately")
|
||||
self.merge_at_end_checkbox = QCheckBox("Create a merged version at the end")
|
||||
|
||||
# Set default states
|
||||
self.save_separately_checkbox.setChecked(True)
|
||||
self.merge_at_end_checkbox.setChecked(True)
|
||||
|
||||
# Connect checkbox state change signal
|
||||
self.save_separately_checkbox.stateChanged.connect(
|
||||
self.update_merge_checkbox_state
|
||||
)
|
||||
|
||||
layout.addWidget(self.save_separately_checkbox)
|
||||
layout.addWidget(self.merge_at_end_checkbox)
|
||||
|
||||
# Add OK button
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Ok)
|
||||
button_box.accepted.connect(self.accept)
|
||||
layout.addWidget(button_box)
|
||||
|
||||
# Initialize merge checkbox state
|
||||
self.update_merge_checkbox_state()
|
||||
|
||||
def update_merge_checkbox_state(self):
|
||||
# Enable merge checkbox only if save separately is checked
|
||||
self.merge_at_end_checkbox.setEnabled(self.save_separately_checkbox.isChecked())
|
||||
# Don't uncheck it, just leave it in its current state
|
||||
|
||||
def get_options(self):
|
||||
save_separately = self.save_separately_checkbox.isChecked()
|
||||
# Consider merge_at_end as false if the checkbox is disabled, regardless of its checked state
|
||||
merge_at_end = (
|
||||
self.merge_at_end_checkbox.isChecked()
|
||||
and self.merge_at_end_checkbox.isEnabled()
|
||||
)
|
||||
return {
|
||||
"save_chapters_separately": save_separately,
|
||||
"merge_chapters_at_end": merge_at_end,
|
||||
}
|
||||
|
||||
# Prevent closing by overriding the closeEvent
|
||||
def closeEvent(self, event):
|
||||
# Ignore all close events
|
||||
event.ignore()
|
||||
|
||||
# Prevent escape key from closing the dialog
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == Qt.Key_Escape:
|
||||
event.ignore()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
class ConversionThread(QThread):
|
||||
progress_updated = pyqtSignal(int, str) # Add str for ETR
|
||||
conversion_finished = pyqtSignal(object, object) # Pass output path as second arg
|
||||
log_updated = pyqtSignal(object) # Updated signal for log updates
|
||||
chapters_detected = pyqtSignal(int) # Signal for chapter detection
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file_name,
|
||||
lang_code,
|
||||
speed,
|
||||
voice,
|
||||
save_option,
|
||||
output_folder,
|
||||
subtitle_mode,
|
||||
output_format,
|
||||
np_module,
|
||||
kpipeline_class,
|
||||
start_time,
|
||||
total_char_count,
|
||||
use_gpu=True,
|
||||
): # Add use_gpu parameter
|
||||
super().__init__()
|
||||
self.np = np_module
|
||||
self.KPipeline = kpipeline_class
|
||||
self.file_name = file_name
|
||||
self.lang_code = lang_code
|
||||
self.speed = speed
|
||||
self.voice = voice
|
||||
self.save_option = save_option
|
||||
self.output_folder = output_folder
|
||||
self.subtitle_mode = subtitle_mode
|
||||
self.cancel_requested = False
|
||||
self.output_format = output_format
|
||||
self.start_time = start_time # Store start_time
|
||||
self.total_char_count = total_char_count # Use passed total character count
|
||||
self.processed_char_count = 0 # Initialize processed character count
|
||||
self.display_path = None # Add variable for display path
|
||||
self.is_direct_text = (
|
||||
False # Flag to indicate if input is from textbox rather than file
|
||||
)
|
||||
self.chapter_options_set = False
|
||||
self.waiting_for_user_input = False
|
||||
self.use_gpu = use_gpu # Store the GPU setting
|
||||
self.max_subtitle_words = 50 # Default value, will be overridden from GUI
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
# Show configuration
|
||||
self.log_updated.emit("Configuration:")
|
||||
# Use display_path for logs if available, otherwise use the actual file name
|
||||
display_file = self.display_path if self.display_path else self.file_name
|
||||
self.log_updated.emit(f"- Input File: {display_file}")
|
||||
|
||||
# Use file size string passed from GUI
|
||||
if hasattr(self, "file_size_str"):
|
||||
self.log_updated.emit(f"- File size: {self.file_size_str}")
|
||||
|
||||
self.log_updated.emit(f"- Total characters: {self.total_char_count:,}")
|
||||
|
||||
self.log_updated.emit(
|
||||
f"- Language: {self.lang_code} ({LANGUAGE_DESCRIPTIONS.get(self.lang_code, 'Unknown')})"
|
||||
)
|
||||
self.log_updated.emit(f"- Voice: {self.voice}")
|
||||
self.log_updated.emit(f"- Speed: {self.speed}")
|
||||
self.log_updated.emit(f"- Subtitle mode: {self.subtitle_mode}")
|
||||
self.log_updated.emit(f"- Output format: {self.output_format}")
|
||||
self.log_updated.emit(f"- Save option: {self.save_option}")
|
||||
|
||||
# Display save_chapters_separately flag if it's set
|
||||
if hasattr(self, "save_chapters_separately"):
|
||||
self.log_updated.emit(
|
||||
(
|
||||
f"- Save chapters separately: {'Yes' if self.save_chapters_separately else 'No'}"
|
||||
)
|
||||
)
|
||||
# Display merge_chapters_at_end flag if save_chapters_separately is True
|
||||
if self.save_chapters_separately:
|
||||
merge_at_end = getattr(self, "merge_chapters_at_end", True)
|
||||
self.log_updated.emit(
|
||||
f"- Merge chapters at the end: {'Yes' if merge_at_end else 'No'}"
|
||||
)
|
||||
|
||||
if self.save_option == "Choose output folder":
|
||||
self.log_updated.emit(
|
||||
f" - Output folder: {self.output_folder or os.getcwd()}"
|
||||
)
|
||||
self.log_updated.emit("\nInitializing TTS pipeline...")
|
||||
|
||||
# Set device based on use_gpu setting
|
||||
device = "cuda" if self.use_gpu else "cpu"
|
||||
tts = self.KPipeline(
|
||||
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
|
||||
)
|
||||
|
||||
if self.is_direct_text:
|
||||
text = self.file_name # Treat file_name as direct text input
|
||||
else:
|
||||
with open(self.file_name, "r", encoding="utf-8") as file:
|
||||
text = file.read()
|
||||
|
||||
# Clean up text using utility function
|
||||
text = clean_text(text)
|
||||
|
||||
# --- Chapter splitting logic ---
|
||||
chapter_pattern = r"<<CHAPTER_MARKER:(.*?)>>"
|
||||
chapter_splits = list(re.finditer(chapter_pattern, text))
|
||||
chapters = []
|
||||
if chapter_splits:
|
||||
for idx, match in enumerate(chapter_splits):
|
||||
start = match.end()
|
||||
end = (
|
||||
chapter_splits[idx + 1].start()
|
||||
if idx + 1 < len(chapter_splits)
|
||||
else len(text)
|
||||
)
|
||||
chapter_name = match.group(1).strip()
|
||||
chapter_text = text[start:end].strip()
|
||||
chapters.append((chapter_name, chapter_text))
|
||||
else:
|
||||
chapters = [("text", text)]
|
||||
total_chapters = len(chapters)
|
||||
|
||||
# For text files with chapters, prompt user for options if not already set
|
||||
is_txt_file = not self.is_direct_text and (
|
||||
self.file_name.lower().endswith(".txt")
|
||||
or (self.display_path and self.display_path.lower().endswith(".txt"))
|
||||
)
|
||||
|
||||
if (
|
||||
is_txt_file
|
||||
and total_chapters > 1
|
||||
and (
|
||||
not hasattr(self, "save_chapters_separately")
|
||||
or not hasattr(self, "merge_chapters_at_end")
|
||||
)
|
||||
and not self.chapter_options_set
|
||||
):
|
||||
|
||||
self.waiting_for_user_input = True
|
||||
# Emit signal to main thread to show dialog
|
||||
self.chapters_detected.emit(total_chapters)
|
||||
|
||||
# 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:
|
||||
self.conversion_finished.emit("Cancelled", None)
|
||||
return
|
||||
|
||||
self.chapter_options_set = True
|
||||
|
||||
# Log all detected chapters at the beginning
|
||||
if total_chapters > 1:
|
||||
chapter_list = "\n".join(
|
||||
[f"{i+1}) {c[0]}" for i, c in enumerate(chapters)]
|
||||
)
|
||||
self.log_updated.emit(
|
||||
(f"\nDetected chapters ({total_chapters}):\n" + chapter_list)
|
||||
)
|
||||
else:
|
||||
self.log_updated.emit((f"\nProcessing {chapters[0][0]}..."))
|
||||
|
||||
# If save_chapters_separately is enabled, find a unique suffix ONCE and use for both folder and merged file
|
||||
save_chapters_separately = getattr(self, "save_chapters_separately", False)
|
||||
chapters_out_dir = None
|
||||
suffix = ""
|
||||
base_path = self.display_path if self.display_path else self.file_name
|
||||
base_name = os.path.splitext(os.path.basename(base_path))[0]
|
||||
if self.save_option == "Save to Desktop":
|
||||
parent_dir = os.path.join(os.path.expanduser("~"), "Desktop")
|
||||
elif self.save_option == "Save next to input file":
|
||||
parent_dir = os.path.dirname(base_path)
|
||||
else:
|
||||
parent_dir = self.output_folder or os.getcwd()
|
||||
# Find a unique suffix for both folder and merged file, always
|
||||
counter = 1
|
||||
while True:
|
||||
suffix = f"_{counter}" if counter > 1 else ""
|
||||
chapters_out_dir_candidate = os.path.join(
|
||||
parent_dir, f"{base_name}{suffix}_chapters"
|
||||
)
|
||||
merged_file_candidate = os.path.join(
|
||||
parent_dir, f"{base_name}{suffix}.{self.output_format}"
|
||||
)
|
||||
merged_srt_candidate = (
|
||||
os.path.splitext(merged_file_candidate)[0] + ".srt"
|
||||
)
|
||||
if (
|
||||
not os.path.exists(chapters_out_dir_candidate)
|
||||
and not os.path.exists(merged_file_candidate)
|
||||
and (
|
||||
self.subtitle_mode == "Disabled"
|
||||
or not os.path.exists(merged_srt_candidate)
|
||||
)
|
||||
):
|
||||
break
|
||||
counter += 1
|
||||
if save_chapters_separately and total_chapters > 1:
|
||||
chapters_out_dir = chapters_out_dir_candidate
|
||||
os.makedirs(chapters_out_dir, exist_ok=True)
|
||||
self.log_updated.emit(f"\nChapters output folder: {chapters_out_dir}")
|
||||
|
||||
audio_segments = []
|
||||
subtitle_entries = []
|
||||
current_time = 0.0
|
||||
rate = 24000
|
||||
subtitle_mode = self.subtitle_mode
|
||||
raw_tts_results = [] # Collect all raw tts Result objects
|
||||
|
||||
# ETR timing starts here, after model loading but before processing
|
||||
self.etr_start_time = time.time()
|
||||
self.processed_char_count = 0 # Initialize processed character count
|
||||
|
||||
# Initialize current segment counter
|
||||
current_segment = 0
|
||||
|
||||
# Instead of processing the whole text, process by chapter
|
||||
for chapter_idx, (chapter_name, chapter_text) in enumerate(chapters, 1):
|
||||
if total_chapters > 1:
|
||||
self.log_updated.emit(
|
||||
(
|
||||
f"\nChapter {chapter_idx}/{total_chapters}: {chapter_name}",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
|
||||
# Variables for per-chapter processing when save_chapters_separately is enabled
|
||||
chapter_audio_segments = []
|
||||
chapter_subtitle_entries = []
|
||||
chapter_current_time = 0.0
|
||||
|
||||
# Set split_pattern to \n+ which will split on one or more newlines
|
||||
split_pattern = r"\n+"
|
||||
for result in tts(
|
||||
chapter_text,
|
||||
voice=self.voice,
|
||||
speed=self.speed,
|
||||
split_pattern=split_pattern,
|
||||
):
|
||||
# Print the result for debugging
|
||||
# print(f"Result: {result}")
|
||||
if self.cancel_requested:
|
||||
self.conversion_finished.emit("Cancelled", None)
|
||||
return
|
||||
current_segment += 1
|
||||
grapheme_len = len(result.graphemes)
|
||||
self.processed_char_count += grapheme_len
|
||||
# Log progress with both character counts and the graphemes content
|
||||
self.log_updated.emit(
|
||||
f"\n{self.processed_char_count:,}/{self.total_char_count:,}: {result.graphemes}"
|
||||
)
|
||||
raw_tts_results.append(result)
|
||||
|
||||
chunk_dur = len(result.audio) / rate
|
||||
chunk_start = current_time
|
||||
audio_segments.append(result.audio)
|
||||
|
||||
# For per-chapter output
|
||||
if save_chapters_separately and total_chapters > 1:
|
||||
chapter_audio_segments.append(result.audio)
|
||||
chapter_chunk_start = chapter_current_time
|
||||
|
||||
# Process token timestamps for subtitle generation
|
||||
if self.subtitle_mode != "Disabled":
|
||||
tokens_list = getattr(result, "tokens", [])
|
||||
tokens_with_timestamps = []
|
||||
chapter_tokens_with_timestamps = []
|
||||
|
||||
# Process every token, regardless of text or timestamps
|
||||
for tok in tokens_list:
|
||||
tokens_with_timestamps.append(
|
||||
{
|
||||
"start": chunk_start + (tok.start_ts or 0),
|
||||
"end": chunk_start + (tok.end_ts or 0),
|
||||
"text": tok.text,
|
||||
"whitespace": tok.whitespace,
|
||||
}
|
||||
)
|
||||
if save_chapters_separately and total_chapters > 1:
|
||||
chapter_tokens_with_timestamps.append(
|
||||
{
|
||||
"start": chapter_chunk_start
|
||||
+ (tok.start_ts or 0),
|
||||
"end": chapter_chunk_start + (tok.end_ts or 0),
|
||||
"text": tok.text,
|
||||
"whitespace": tok.whitespace,
|
||||
}
|
||||
)
|
||||
|
||||
# Process tokens according to subtitle mode
|
||||
# Global subtitle processing
|
||||
self._process_subtitle_tokens(
|
||||
tokens_with_timestamps,
|
||||
subtitle_entries,
|
||||
self.max_subtitle_words,
|
||||
)
|
||||
|
||||
# Per-chapter subtitle processing if enabled
|
||||
if save_chapters_separately and total_chapters > 1:
|
||||
self._process_subtitle_tokens(
|
||||
chapter_tokens_with_timestamps,
|
||||
chapter_subtitle_entries,
|
||||
self.max_subtitle_words,
|
||||
)
|
||||
|
||||
current_time += chunk_dur
|
||||
|
||||
# Update chapter_current_time for per-chapter output
|
||||
if save_chapters_separately and total_chapters > 1:
|
||||
chapter_current_time += chunk_dur
|
||||
|
||||
# Calculate percentage based on characters processed
|
||||
percent = min(
|
||||
int(self.processed_char_count / self.total_char_count * 100), 99
|
||||
)
|
||||
|
||||
# Calculate ETR based on characters processed
|
||||
etr_str = "Estimating..."
|
||||
chars_done = self.processed_char_count
|
||||
elapsed = time.time() - self.etr_start_time
|
||||
|
||||
# Calculate ETR if enough data is available
|
||||
if (
|
||||
chars_done > 0 and elapsed > 0.5
|
||||
): # Check elapsed > 0.5 to avoid instability
|
||||
avg_time_per_char = elapsed / chars_done
|
||||
remaining = self.total_char_count - self.processed_char_count
|
||||
if remaining > 0:
|
||||
secs = avg_time_per_char * remaining
|
||||
h = int(secs // 3600)
|
||||
m = int((secs % 3600) // 60)
|
||||
s = int(secs % 60)
|
||||
etr_str = f"{h:02d}:{m:02d}:{s:02d}"
|
||||
|
||||
# Update progress more frequently (after each result)
|
||||
self.progress_updated.emit(percent, etr_str)
|
||||
|
||||
# Save the individual chapter output if save_chapters_separately is enabled
|
||||
if (
|
||||
save_chapters_separately
|
||||
and total_chapters > 1
|
||||
and chapters_out_dir
|
||||
and chapter_audio_segments
|
||||
):
|
||||
# Sanitize chapter name for use in filenames
|
||||
sanitized_chapter_name = re.sub(r"[^\w\-\. ]", "_", chapter_name)
|
||||
sanitized_chapter_name = re.sub(
|
||||
r"_+", "_", sanitized_chapter_name
|
||||
) # Replace multiple underscores with one
|
||||
chapter_filename = f"{chapter_idx:02d}_{sanitized_chapter_name}"
|
||||
|
||||
# Concatenate chapter audio and save
|
||||
chapter_audio = self.np.concatenate(chapter_audio_segments)
|
||||
chapter_out_path = os.path.join(
|
||||
chapters_out_dir, f"{chapter_filename}.{self.output_format}"
|
||||
)
|
||||
sf.write(
|
||||
chapter_out_path,
|
||||
chapter_audio,
|
||||
24000,
|
||||
format=self.output_format,
|
||||
)
|
||||
|
||||
# Generate .srt subtitle file for chapter if not Disabled
|
||||
if self.subtitle_mode != "Disabled" and chapter_subtitle_entries:
|
||||
chapter_srt_path = os.path.join(
|
||||
chapters_out_dir, f"{chapter_filename}.srt"
|
||||
)
|
||||
with open(chapter_srt_path, "w", encoding="utf-8") as srt_file:
|
||||
for i, (start, end, text) in enumerate(
|
||||
chapter_subtitle_entries, 1
|
||||
):
|
||||
srt_file.write(
|
||||
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
)
|
||||
|
||||
self.log_updated.emit(
|
||||
(
|
||||
f"\nChapter {chapter_idx} saved to: {chapter_out_path}\n\nSubtitle saved to: {chapter_srt_path}",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.log_updated.emit(
|
||||
(
|
||||
f"\nChapter {chapter_idx} saved to: {chapter_out_path}",
|
||||
"green",
|
||||
)
|
||||
)
|
||||
|
||||
# Set progress to 100% when processing is complete
|
||||
self.progress_updated.emit(100, "00:00:00")
|
||||
|
||||
# Only generate the merged output file if merge_chapters_at_end is True or save_chapters_separately is False
|
||||
merge_chapters = (
|
||||
not hasattr(self, "save_chapters_separately")
|
||||
or not self.save_chapters_separately
|
||||
or getattr(self, "merge_chapters_at_end", True)
|
||||
)
|
||||
if audio_segments and merge_chapters:
|
||||
self.log_updated.emit("\nFinalizing audio file...\n")
|
||||
audio = self.np.concatenate(audio_segments)
|
||||
out_dir = parent_dir
|
||||
# Use the same suffix as above
|
||||
out_filename = f"{base_name}{suffix}.{self.output_format}"
|
||||
out_path = os.path.join(out_dir, out_filename)
|
||||
srt_path = os.path.splitext(out_path)[0] + ".srt"
|
||||
sf.write(out_path, audio, 24000, format=self.output_format)
|
||||
if self.subtitle_mode != "Disabled":
|
||||
with open(srt_path, "w", encoding="utf-8") as srt_file:
|
||||
for i, (start, end, text) in enumerate(subtitle_entries, 1):
|
||||
srt_file.write(
|
||||
f"{i}\n{self._srt_time(start)} --> {self._srt_time(end)}\n{text}\n\n"
|
||||
)
|
||||
self.conversion_finished.emit(
|
||||
(
|
||||
f"Audiobook saved to: {out_path}\n\nSubtitle saved to: {srt_path}",
|
||||
"green",
|
||||
),
|
||||
out_path,
|
||||
)
|
||||
else:
|
||||
self.conversion_finished.emit(
|
||||
(f"Audiobook saved to: {out_path}", "green"), out_path
|
||||
)
|
||||
elif audio_segments and not merge_chapters:
|
||||
self.conversion_finished.emit(
|
||||
(
|
||||
f"\nAll chapters processed successfully and saved to: {chapters_out_dir}",
|
||||
"green",
|
||||
),
|
||||
chapters_out_dir,
|
||||
)
|
||||
else:
|
||||
self.log_updated.emit(("No audio segments were generated.", "red"))
|
||||
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
|
||||
except Exception as e:
|
||||
self.log_updated.emit((f"Error occurred: {str(e)}", "red"))
|
||||
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
|
||||
|
||||
def set_chapter_options(self, options):
|
||||
"""Set chapter options from the dialog and resume processing"""
|
||||
self.save_chapters_separately = options["save_chapters_separately"]
|
||||
self.merge_chapters_at_end = options["merge_chapters_at_end"]
|
||||
self.waiting_for_user_input = False
|
||||
|
||||
def _srt_time(self, t):
|
||||
"""Helper function to format time for SRT files"""
|
||||
h = int(t // 3600)
|
||||
m = int((t % 3600) // 60)
|
||||
s = int(t % 60)
|
||||
ms = int((t - int(t)) * 1000)
|
||||
return f"{h:02}:{m:02}:{s:02},{ms:03}"
|
||||
|
||||
def _process_subtitle_tokens(
|
||||
self, tokens_with_timestamps, subtitle_entries, max_subtitle_words
|
||||
):
|
||||
"""Helper function to process subtitle tokens according to the subtitle mode"""
|
||||
if not tokens_with_timestamps:
|
||||
return
|
||||
|
||||
if self.subtitle_mode == "Sentence" or self.subtitle_mode == "Sentence + Comma":
|
||||
# Define separator pattern based on mode
|
||||
separator = r"[.!?]" if self.subtitle_mode == "Sentence" else r"[.!?,]"
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
for token in tokens_with_timestamps:
|
||||
current_sentence.append(token)
|
||||
word_count += 1
|
||||
|
||||
# Split sentences based on separator or word count
|
||||
if (
|
||||
re.search(separator, token["text"]) and token["whitespace"] == " "
|
||||
) or word_count >= max_subtitle_words:
|
||||
if current_sentence:
|
||||
# Create subtitle entry for this sentence
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Simplified text joining logic
|
||||
sentence_text = ""
|
||||
for t in current_sentence:
|
||||
sentence_text += t["text"] + (t.get("whitespace", "") or "")
|
||||
|
||||
subtitle_entries.append(
|
||||
(start_time, end_time, sentence_text.strip())
|
||||
)
|
||||
current_sentence = []
|
||||
word_count = 0
|
||||
|
||||
# Add any remaining tokens as a sentence
|
||||
if current_sentence:
|
||||
start_time = current_sentence[0]["start"]
|
||||
end_time = current_sentence[-1]["end"]
|
||||
|
||||
# Simplified text joining logic
|
||||
sentence_text = ""
|
||||
for t in current_sentence:
|
||||
sentence_text += t["text"] + (t.get("whitespace", "") or "")
|
||||
|
||||
subtitle_entries.append((start_time, end_time, sentence_text.strip()))
|
||||
|
||||
else:
|
||||
# Word count-based grouping
|
||||
try:
|
||||
word_count = int(self.subtitle_mode.split()[0])
|
||||
word_count = min(word_count, max_subtitle_words)
|
||||
except (ValueError, IndexError):
|
||||
word_count = 1
|
||||
|
||||
# Combine punctuation with preceding words
|
||||
processed_tokens = []
|
||||
i = 0
|
||||
while i < len(tokens_with_timestamps):
|
||||
token = tokens_with_timestamps[i].copy()
|
||||
|
||||
# Look ahead for punctuation
|
||||
while i + 1 < len(tokens_with_timestamps) and re.match(
|
||||
r"^[^\w\s]+$", tokens_with_timestamps[i + 1]["text"]
|
||||
):
|
||||
token["text"] += tokens_with_timestamps[i + 1]["text"]
|
||||
token["end"] = tokens_with_timestamps[i + 1]["end"]
|
||||
token["whitespace"] = tokens_with_timestamps[i + 1]["whitespace"]
|
||||
i += 1
|
||||
|
||||
processed_tokens.append(token)
|
||||
i += 1
|
||||
|
||||
# Group words into subtitle entries
|
||||
for i in range(0, len(processed_tokens), word_count):
|
||||
group = processed_tokens[i : i + word_count]
|
||||
if group:
|
||||
text = "".join(
|
||||
t["text"] + (t.get("whitespace", "") or "") for t in group
|
||||
)
|
||||
subtitle_entries.append(
|
||||
(group[0]["start"], group[-1]["end"], text.strip())
|
||||
)
|
||||
|
||||
def cancel(self):
|
||||
self.cancel_requested = True
|
||||
self.waiting_for_user_input = (
|
||||
False # Also release the wait if we're waiting for input
|
||||
)
|
||||
|
||||
|
||||
class VoicePreviewThread(QThread):
|
||||
finished = pyqtSignal()
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(
|
||||
self, np_module, kpipeline_class, lang_code, voice, speed, parent=None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.np_module = np_module
|
||||
self.kpipeline_class = kpipeline_class
|
||||
self.lang_code = lang_code
|
||||
self.voice = voice
|
||||
self.speed = speed
|
||||
self.temp_wav = None
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
tts = self.kpipeline_class(
|
||||
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M"
|
||||
)
|
||||
sample_text = get_sample_voice_text(self.lang_code)
|
||||
audio_segments = []
|
||||
for result in tts(
|
||||
sample_text, voice=self.voice, speed=self.speed, split_pattern=None
|
||||
):
|
||||
audio_segments.append(result.audio)
|
||||
if audio_segments:
|
||||
audio = self.np_module.concatenate(audio_segments)
|
||||
# Create temp wav file in a folder in the system temp directory
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), PROGRAM_NAME)
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
fd, temp_path = tempfile.mkstemp(
|
||||
prefix="abogen_", suffix=".wav", dir=temp_dir
|
||||
)
|
||||
os.close(fd)
|
||||
sf.write(temp_path, audio, 24000)
|
||||
self.temp_wav = temp_path
|
||||
self.finished.emit()
|
||||
except Exception as e:
|
||||
self.error.emit(f"Voice preview error: {str(e)}")
|
||||
|
||||
|
||||
class PlayAudioThread(QThread):
|
||||
finished = pyqtSignal()
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(self, wav_path, parent=None):
|
||||
super().__init__(parent)
|
||||
self.wav_path = wav_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
import pygame
|
||||
import time as _time
|
||||
pygame.mixer.init()
|
||||
pygame.mixer.music.load(self.wav_path)
|
||||
pygame.mixer.music.play()
|
||||
# Wait until playback is finished
|
||||
while pygame.mixer.music.get_busy():
|
||||
_time.sleep(0.1)
|
||||
pygame.mixer.music.unload()
|
||||
self.finished.emit()
|
||||
except Exception as e:
|
||||
self.error.emit(f"Audio playback error: {str(e)}")
|
||||
+1865
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from PyQt5.QtGui import QIcon
|
||||
from abogen.gui import abogen
|
||||
from abogen.utils import get_resource_path
|
||||
|
||||
# Ensure sys.stdout and sys.stderr are valid in GUI mode
|
||||
if sys.stdout is None:
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
if sys.stderr is None:
|
||||
sys.stderr = open(os.devnull, "w")
|
||||
|
||||
# Enable MPS GPU acceleration on Mac Apple Silicon
|
||||
if platform.system() == "Darwin" and platform.processor() == "arm":
|
||||
os.environ["PYTORCH_ENABLE_MPS_FALLBACK"] = "1"
|
||||
|
||||
# Set application ID for Windows taskbar icon
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
|
||||
app_id = "abogen.v1.0.0"
|
||||
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id)
|
||||
|
||||
# Handle Wayland on Linux GNOME
|
||||
if platform.system() == "Linux":
|
||||
xdg_session = os.environ.get("XDG_SESSION_TYPE", "").lower()
|
||||
desktop = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||
if "gnome" in desktop and xdg_session == "wayland" and "QT_QPA_PLATFORM" not in os.environ:
|
||||
os.environ["QT_QPA_PLATFORM"] = "wayland"
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for console usage."""
|
||||
app = QApplication(sys.argv)
|
||||
|
||||
# Set application icon using get_resource_path from utils
|
||||
icon_path = get_resource_path("abogen.assets", "icon.ico")
|
||||
if icon_path:
|
||||
app.setWindowIcon(QIcon(icon_path))
|
||||
|
||||
ex = abogen()
|
||||
ex.show()
|
||||
sys.exit(app.exec_())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
import os
|
||||
import json
|
||||
import warnings
|
||||
import platform
|
||||
import subprocess
|
||||
import re
|
||||
from threading import Thread
|
||||
|
||||
# suppress warnings and disable HF hub symlink warnings
|
||||
os.environ["HF_HUB_DISABLE_SYMLINKS_WARNING"] = "1"
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
# Language description mapping
|
||||
LANGUAGE_DESCRIPTIONS = {
|
||||
"a": "American English",
|
||||
"b": "British English",
|
||||
"e": "Spanish",
|
||||
"f": "French",
|
||||
"h": "Hindi",
|
||||
"i": "Italian",
|
||||
"j": "Japanese",
|
||||
"p": "Brazilian Portuguese",
|
||||
"z": "Mandarin Chinese",
|
||||
}
|
||||
|
||||
# Supported languages for subtitle generation
|
||||
# Currently, only 'a (American English)' and 'b (British English)' are supported for subtitle generation.
|
||||
# This is because tokens that contain timestamps are not generated for other languages in the Kokoro pipeline.
|
||||
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py
|
||||
# 383 English processing (unchanged)
|
||||
# 384 if self.lang_code in 'ab':
|
||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = [
|
||||
"a",
|
||||
"b",
|
||||
]
|
||||
|
||||
# Voice and sample text constants
|
||||
VOICES_INTERNAL = [
|
||||
"af_alloy",
|
||||
"af_aoede",
|
||||
"af_bella",
|
||||
"af_heart",
|
||||
"af_jessica",
|
||||
"af_kore",
|
||||
"af_nicole",
|
||||
"af_nova",
|
||||
"af_river",
|
||||
"af_sarah",
|
||||
"af_sky",
|
||||
"am_adam",
|
||||
"am_echo",
|
||||
"am_eric",
|
||||
"am_fenrir",
|
||||
"am_liam",
|
||||
"am_michael",
|
||||
"am_onyx",
|
||||
"am_puck",
|
||||
"am_santa",
|
||||
"bf_alice",
|
||||
"bf_emma",
|
||||
"bf_isabella",
|
||||
"bf_lily",
|
||||
"bm_daniel",
|
||||
"bm_fable",
|
||||
"bm_george",
|
||||
"bm_lewis",
|
||||
"ef_dora",
|
||||
"em_alex",
|
||||
"em_santa",
|
||||
"ff_siwis",
|
||||
"hf_alpha",
|
||||
"hf_beta",
|
||||
"hm_omega",
|
||||
"hm_psi",
|
||||
"if_sara",
|
||||
"im_nicola",
|
||||
"jf_alpha",
|
||||
"jf_gongitsune",
|
||||
"jf_nezumi",
|
||||
"jf_tebukuro",
|
||||
"jm_kumo",
|
||||
"pf_dora",
|
||||
"pm_alex",
|
||||
"pm_santa",
|
||||
"zf_xiaobei",
|
||||
"zf_xiaoni",
|
||||
"zf_xiaoxiao",
|
||||
"zf_xiaoyi",
|
||||
"zm_yunjian",
|
||||
"zm_yunxi",
|
||||
"zm_yunxia",
|
||||
"zm_yunyang",
|
||||
]
|
||||
|
||||
# Voice and sample text mapping
|
||||
SAMPLE_VOICE_TEXTS = {
|
||||
"a": "This is a sample of the selected voice.",
|
||||
"b": "This is a sample of the selected voice.",
|
||||
"e": "Este es una muestra de la voz seleccionada.",
|
||||
"f": "Ceci est un exemple de la voix sélectionnée.",
|
||||
"h": "यह चयनित आवाज़ का एक नमूना है।",
|
||||
"i": "Questo è un esempio della voce selezionata.",
|
||||
"j": "これは選択した声のサンプルです。",
|
||||
"p": "Este é um exemplo da voz selecionada.",
|
||||
"z": "这是所选语音的示例。",
|
||||
}
|
||||
|
||||
# flags mapping for voice display
|
||||
FLAGS = {
|
||||
"a": "🇺🇸",
|
||||
"b": "🇬🇧",
|
||||
"e": "🇪🇸",
|
||||
"f": "🇫🇷",
|
||||
"h": "🇮🇳",
|
||||
"i": "🇮🇹",
|
||||
"j": "🇯🇵",
|
||||
"p": "🇧🇷",
|
||||
"z": "🇨🇳",
|
||||
}
|
||||
|
||||
|
||||
def get_resource_path(package, resource):
|
||||
"""
|
||||
Get the path to a resource file, with fallback to local file system.
|
||||
|
||||
Args:
|
||||
package (str): Package name containing the resource (e.g., 'abogen.assets')
|
||||
resource (str): Resource filename (e.g., 'icon.ico')
|
||||
|
||||
Returns:
|
||||
str: Path to the resource file, or None if not found
|
||||
"""
|
||||
from importlib import resources
|
||||
|
||||
# Try using importlib.resources first
|
||||
try:
|
||||
with resources.path(package, resource) as resource_path:
|
||||
if os.path.exists(resource_path):
|
||||
return str(resource_path)
|
||||
except (ImportError, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Fallback to local file system
|
||||
try:
|
||||
# Extract the subdirectory from package name (e.g., 'assets' from 'abogen.assets')
|
||||
subdir = package.split(".")[-1] if "." in package else package
|
||||
local_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), subdir, resource
|
||||
)
|
||||
if os.path.exists(local_path):
|
||||
return local_path
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_version():
|
||||
"""Return the current version of the application."""
|
||||
try:
|
||||
with open(get_resource_path("abogen", "VERSION"), "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
return "Unknown"
|
||||
|
||||
|
||||
# Define config path
|
||||
def get_user_config_path():
|
||||
if os.name == "nt":
|
||||
config_dir = os.path.join(os.environ["APPDATA"], "abogen")
|
||||
else:
|
||||
config_dir = os.path.join(os.path.expanduser("~"), ".config", "abogen")
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
return os.path.join(config_dir, "config.json")
|
||||
|
||||
|
||||
CONFIG_PATH = get_user_config_path()
|
||||
|
||||
_sleep_procs = {"Darwin": None, "Linux": None} # Store sleep prevention processes
|
||||
|
||||
|
||||
def clean_text(text):
|
||||
# Trim spaces and tabs at the start and end of each line, preserving blank lines
|
||||
text = "\n".join(line.strip() for line in text.splitlines())
|
||||
# Standardize paragraph breaks (multiple newlines become exactly two) and trim overall whitespace
|
||||
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
# Replace single newlines with spaces, but preserve double newlines
|
||||
# text = re.sub(r"(?<!\n)\n(?!\n)", " ", text)
|
||||
# Collapse multiple spaces and tabs into a single space
|
||||
text = re.sub(r"[ \t]+", " ", text)
|
||||
return text
|
||||
|
||||
|
||||
def load_config():
|
||||
try:
|
||||
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_config(config):
|
||||
try:
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def calculate_text_length(text):
|
||||
# Remove double newlines (replace them with single newlines)
|
||||
cleaned_text = text.replace("\n\n", "")
|
||||
# Calculate character count
|
||||
char_count = len(cleaned_text)
|
||||
return char_count
|
||||
|
||||
|
||||
def get_gpu_acceleration(enabled):
|
||||
from torch.cuda import is_available
|
||||
|
||||
if not enabled:
|
||||
return "CUDA GPU available but using CPU.", False
|
||||
|
||||
if is_available():
|
||||
return "CUDA GPU available and enabled.", True
|
||||
return "CUDA GPU is not available. Using CPU.", False
|
||||
|
||||
|
||||
def prevent_sleep_start():
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(
|
||||
0x80000000 | 0x00000001 | 0x00000040
|
||||
) # ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_AWAYMODE_REQUIRED
|
||||
elif system == "Darwin":
|
||||
_sleep_procs["Darwin"] = subprocess.Popen(["caffeinate"])
|
||||
elif system == "Linux":
|
||||
try:
|
||||
_sleep_procs["Linux"] = subprocess.Popen(
|
||||
[
|
||||
"systemd-inhibit",
|
||||
"--what=sleep",
|
||||
"--why=TextToAudiobook conversion",
|
||||
"sleep",
|
||||
"999999",
|
||||
]
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
subprocess.Popen(["xdg-screensaver", "reset"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def prevent_sleep_end():
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.kernel32.SetThreadExecutionState(0x80000000) # ES_CONTINUOUS
|
||||
elif system in ("Darwin", "Linux") and _sleep_procs[system]:
|
||||
try:
|
||||
_sleep_procs[system].terminate()
|
||||
_sleep_procs[system] = None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def load_numpy_kpipeline():
|
||||
import numpy as np
|
||||
from kokoro import KPipeline
|
||||
|
||||
return np, KPipeline
|
||||
|
||||
|
||||
class LoadPipelineThread(Thread):
|
||||
def __init__(self, callback):
|
||||
super().__init__()
|
||||
self.callback = callback
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
np_module, kpipeline_class = load_numpy_kpipeline()
|
||||
self.callback(np_module, kpipeline_class, None)
|
||||
except Exception as e:
|
||||
self.callback(None, None, str(e))
|
||||
Reference in New Issue
Block a user