Initial commit

This commit is contained in:
Deniz Şafak
2025-04-26 01:50:03 +03:00
parent a7afc52618
commit 20da35a137
25 changed files with 5476 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# Add chapter metadata for .m4a files using ffmpeg.
+1
View File
@@ -0,0 +1 @@
1.0.0
+9
View File
@@ -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()
+94
View File
@@ -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
+700
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -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
View File
@@ -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))