Reformat using black

This commit is contained in:
Deniz Şafak
2025-11-19 01:54:11 +03:00
parent 43f5589a9d
commit bb5c4204de
8 changed files with 572 additions and 311 deletions
+2
View File
@@ -377,6 +377,7 @@ class HandlerDialog(QDialog):
# Include replace_single_newlines in cache key since it affects text cleaning # Include replace_single_newlines in cache key since it affects text cleaning
from abogen.utils import load_config from abogen.utils import load_config
cfg = load_config() cfg = load_config()
replace_single_newlines = cfg.get("replace_single_newlines", False) replace_single_newlines = cfg.get("replace_single_newlines", False)
@@ -2240,6 +2241,7 @@ class HandlerDialog(QDialog):
if metadata.get("cover_image"): if metadata.get("cover_image"):
try: try:
import uuid import uuid
cache_dir = get_user_cache_path() cache_dir = get_user_cache_path()
cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg") cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg")
cover_path = os.path.normpath(cover_path) cover_path = os.path.normpath(cover_path)
+1 -3
View File
@@ -2,9 +2,7 @@ from abogen.utils import get_version
# Program Information # Program Information
PROGRAM_NAME = "abogen" PROGRAM_NAME = "abogen"
PROGRAM_DESCRIPTION = ( PROGRAM_DESCRIPTION = "Generate audiobooks from EPUBs, PDFs, text and subtitles with synchronized captions."
"Generate audiobooks from EPUBs, PDFs, text and subtitles with synchronized captions."
)
GITHUB_URL = "https://github.com/denizsafak/abogen" GITHUB_URL = "https://github.com/denizsafak/abogen"
VERSION = get_version() VERSION = get_version()
+377 -147
View File
@@ -32,9 +32,9 @@ import platform
def clean_subtitle_text(text): def clean_subtitle_text(text):
"""Remove chapter markers and metadata tags from subtitle text.""" """Remove chapter markers and metadata tags from subtitle text."""
# Remove metadata tags # Remove metadata tags
text = re.sub(r'<<METADATA_[^:]+:[^>]*>>', '', text) text = re.sub(r"<<METADATA_[^:]+:[^>]*>>", "", text)
# Remove chapter markers # Remove chapter markers
text = re.sub(r'<<CHAPTER_MARKER:[^>]*>>', '', text) text = re.sub(r"<<CHAPTER_MARKER:[^>]*>>", "", text)
return text.strip() return text.strip()
@@ -49,43 +49,46 @@ def parse_srt_file(file_path):
List of tuples: [(start_time_seconds, end_time_seconds, text), ...] List of tuples: [(start_time_seconds, end_time_seconds, text), ...]
""" """
encoding = detect_encoding(file_path) encoding = detect_encoding(file_path)
with open(file_path, 'r', encoding=encoding, errors='replace') as f: with open(file_path, "r", encoding=encoding, errors="replace") as f:
content = f.read() content = f.read()
# Split by double newlines to get individual subtitle blocks # Split by double newlines to get individual subtitle blocks
blocks = re.split(r'\n\s*\n', content.strip()) blocks = re.split(r"\n\s*\n", content.strip())
subtitles = [] subtitles = []
for block in blocks: for block in blocks:
if not block.strip(): if not block.strip():
continue continue
lines = block.strip().split('\n') lines = block.strip().split("\n")
if len(lines) < 3: if len(lines) < 3:
continue continue
# First line is index, second line is timestamp, rest is text # First line is index, second line is timestamp, rest is text
try: try:
timestamp_line = lines[1] timestamp_line = lines[1]
match = re.match(r'(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})', timestamp_line) match = re.match(
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})",
timestamp_line,
)
if not match: if not match:
continue continue
start_str = match.group(1) start_str = match.group(1)
end_str = match.group(2) end_str = match.group(2)
text = '\n'.join(lines[2:]) text = "\n".join(lines[2:])
# Convert timestamp to seconds # Convert timestamp to seconds
def time_to_seconds(t): def time_to_seconds(t):
h, m, s_ms = t.split(':') h, m, s_ms = t.split(":")
s, ms = s_ms.split(',') s, ms = s_ms.split(",")
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0 return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
start_sec = time_to_seconds(start_str) start_sec = time_to_seconds(start_str)
end_sec = time_to_seconds(end_str) end_sec = time_to_seconds(end_str)
# Clean text of any styling tags # Clean text of any styling tags
text = re.sub(r'<[^>]+>', '', text) text = re.sub(r"<[^>]+>", "", text)
# Remove chapter markers and metadata tags # Remove chapter markers and metadata tags
text = clean_subtitle_text(text) text = clean_subtitle_text(text)
@@ -108,23 +111,23 @@ def parse_vtt_file(file_path):
List of tuples: [(start_time_seconds, end_time_seconds, text), ...] List of tuples: [(start_time_seconds, end_time_seconds, text), ...]
""" """
encoding = detect_encoding(file_path) encoding = detect_encoding(file_path)
with open(file_path, 'r', encoding=encoding, errors='replace') as f: with open(file_path, "r", encoding=encoding, errors="replace") as f:
content = f.read() content = f.read()
# Remove WEBVTT header and any style/note blocks # Remove WEBVTT header and any style/note blocks
content = re.sub(r'^WEBVTT.*?\n', '', content, flags=re.MULTILINE) content = re.sub(r"^WEBVTT.*?\n", "", content, flags=re.MULTILINE)
content = re.sub(r'STYLE\s*\n.*?(?=\n\n|$)', '', content, flags=re.DOTALL) content = re.sub(r"STYLE\s*\n.*?(?=\n\n|$)", "", content, flags=re.DOTALL)
content = re.sub(r'NOTE\s*\n.*?(?=\n\n|$)', '', content, flags=re.DOTALL) content = re.sub(r"NOTE\s*\n.*?(?=\n\n|$)", "", content, flags=re.DOTALL)
# Split by double newlines to get individual subtitle blocks # Split by double newlines to get individual subtitle blocks
blocks = re.split(r'\n\s*\n', content.strip()) blocks = re.split(r"\n\s*\n", content.strip())
subtitles = [] subtitles = []
for block in blocks: for block in blocks:
if not block.strip(): if not block.strip():
continue continue
lines = block.strip().split('\n') lines = block.strip().split("\n")
if len(lines) < 2: if len(lines) < 2:
continue continue
@@ -133,10 +136,10 @@ def parse_vtt_file(file_path):
text_start_idx = 0 text_start_idx = 0
# Check if first line is timestamp # Check if first line is timestamp
if '-->' in lines[0]: if "-->" in lines[0]:
timestamp_line = lines[0] timestamp_line = lines[0]
text_start_idx = 1 text_start_idx = 1
elif len(lines) > 1 and '-->' in lines[1]: elif len(lines) > 1 and "-->" in lines[1]:
timestamp_line = lines[1] timestamp_line = lines[1]
text_start_idx = 2 text_start_idx = 2
else: else:
@@ -144,24 +147,24 @@ def parse_vtt_file(file_path):
try: try:
# VTT format: 00:00:00.000 --> 00:00:05.000 or 00:00.000 --> 00:05.000 # VTT format: 00:00:00.000 --> 00:00:05.000 or 00:00.000 --> 00:05.000
match = re.match(r'([\d:.]+)\s*-->\s*([\d:.]+)', timestamp_line) match = re.match(r"([\d:.]+)\s*-->\s*([\d:.]+)", timestamp_line)
if not match: if not match:
continue continue
start_str = match.group(1) start_str = match.group(1)
end_str = match.group(2) end_str = match.group(2)
text = '\n'.join(lines[text_start_idx:]) text = "\n".join(lines[text_start_idx:])
# Convert timestamp to seconds # Convert timestamp to seconds
def time_to_seconds(t): def time_to_seconds(t):
parts = t.split(':') parts = t.split(":")
if len(parts) == 3: # HH:MM:SS.mmm if len(parts) == 3: # HH:MM:SS.mmm
h, m, s = parts h, m, s = parts
s, ms = s.split('.') s, ms = s.split(".")
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0 return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
elif len(parts) == 2: # MM:SS.mmm elif len(parts) == 2: # MM:SS.mmm
m, s = parts m, s = parts
s, ms = s.split('.') s, ms = s.split(".")
return int(m) * 60 + int(s) + int(ms) / 1000.0 return int(m) * 60 + int(s) + int(ms) / 1000.0
return 0 return 0
@@ -169,8 +172,8 @@ def parse_vtt_file(file_path):
end_sec = time_to_seconds(end_str) end_sec = time_to_seconds(end_str)
# Clean text of any styling tags and cue settings # Clean text of any styling tags and cue settings
text = re.sub(r'<[^>]+>', '', text) text = re.sub(r"<[^>]+>", "", text)
text = re.sub(r'{[^}]+}', '', text) # Remove voice tags text = re.sub(r"{[^}]+}", "", text) # Remove voice tags
# Remove chapter markers and metadata tags # Remove chapter markers and metadata tags
text = clean_subtitle_text(text) text = clean_subtitle_text(text)
@@ -186,12 +189,14 @@ def detect_timestamps_in_text(file_path):
"""Detect if text file contains timestamp markers (HH:MM:SS or HH:MM:SS,ms format) on separate lines.""" """Detect if text file contains timestamp markers (HH:MM:SS or HH:MM:SS,ms format) on separate lines."""
try: try:
encoding = detect_encoding(file_path) encoding = detect_encoding(file_path)
with open(file_path, 'r', encoding=encoding, errors='replace') as f: with open(file_path, "r", encoding=encoding, errors="replace") as f:
lines = [line.strip() for line in f.readlines()[:50] if line.strip()] # Check first 50 non-empty lines lines = [
line.strip() for line in f.readlines()[:50] if line.strip()
] # Check first 50 non-empty lines
# Count lines that are ONLY timestamps (no other text) # Count lines that are ONLY timestamps (no other text)
# Supports HH:MM:SS or HH:MM:SS,ms format # Supports HH:MM:SS or HH:MM:SS,ms format
timestamp_pattern = r'^(\d{1,2}:\d{2}:\d{2}(?:,\d{1,3})?)$' timestamp_pattern = r"^(\d{1,2}:\d{2}:\d{2}(?:,\d{1,3})?)$"
timestamp_lines = sum(1 for line in lines if re.match(timestamp_pattern, line)) timestamp_lines = sum(1 for line in lines if re.match(timestamp_pattern, line))
# Must have at least 2 timestamp-only lines and they should be >5% of total lines # Must have at least 2 timestamp-only lines and they should be >5% of total lines
@@ -204,23 +209,23 @@ def parse_timestamp_text_file(file_path):
"""Parse text file with timestamps. Returns list of (start_time, end_time, text) tuples. """Parse text file with timestamps. Returns list of (start_time, end_time, text) tuples.
Supports HH:MM:SS or HH:MM:SS,ms format. Returns time in seconds as float.""" Supports HH:MM:SS or HH:MM:SS,ms format. Returns time in seconds as float."""
encoding = detect_encoding(file_path) encoding = detect_encoding(file_path)
with open(file_path, 'r', encoding=encoding, errors='replace') as f: with open(file_path, "r", encoding=encoding, errors="replace") as f:
content = f.read() content = f.read()
# Split by timestamp pattern (supports HH:MM:SS or HH:MM:SS,ms) # Split by timestamp pattern (supports HH:MM:SS or HH:MM:SS,ms)
pattern = r'^(\d{1,2}:\d{2}:\d{2}(?:,\d{1,3})?)$' pattern = r"^(\d{1,2}:\d{2}:\d{2}(?:,\d{1,3})?)$"
lines = content.split('\n') lines = content.split("\n")
def parse_time(time_str): def parse_time(time_str):
"""Convert HH:MM:SS or HH:MM:SS,ms to seconds as float.""" """Convert HH:MM:SS or HH:MM:SS,ms to seconds as float."""
if ',' in time_str: if "," in time_str:
time_part, ms_part = time_str.split(',') time_part, ms_part = time_str.split(",")
parts = time_part.split(':') parts = time_part.split(":")
seconds = int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]) seconds = int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
milliseconds = int(ms_part.ljust(3, '0')) # Pad to 3 digits milliseconds = int(ms_part.ljust(3, "0")) # Pad to 3 digits
return seconds + milliseconds / 1000.0 return seconds + milliseconds / 1000.0
else: else:
parts = time_str.split(':') parts = time_str.split(":")
return float(int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])) return float(int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]))
entries = [] entries = []
@@ -233,12 +238,12 @@ def parse_timestamp_text_file(file_path):
if match: if match:
# Save previous entry # Save previous entry
if current_time is not None and current_text: if current_time is not None and current_text:
text = '\n'.join(current_text).strip() text = "\n".join(current_text).strip()
if text: if text:
entries.append((current_time, text)) entries.append((current_time, text))
elif current_time is None and pre_timestamp_text: elif current_time is None and pre_timestamp_text:
# First timestamp found, save pre-timestamp text with time 0 # First timestamp found, save pre-timestamp text with time 0
text = '\n'.join(pre_timestamp_text).strip() text = "\n".join(pre_timestamp_text).strip()
if text: if text:
entries.append((0.0, text)) entries.append((0.0, text))
pre_timestamp_text = [] pre_timestamp_text = []
@@ -255,12 +260,12 @@ def parse_timestamp_text_file(file_path):
# Save last entry # Save last entry
if current_time is not None and current_text: if current_time is not None and current_text:
text = '\n'.join(current_text).strip() text = "\n".join(current_text).strip()
if text: if text:
entries.append((current_time, text)) entries.append((current_time, text))
elif not entries and pre_timestamp_text: elif not entries and pre_timestamp_text:
# No timestamps found at all, treat entire file as starting at 0 # No timestamps found at all, treat entire file as starting at 0
text = '\n'.join(pre_timestamp_text).strip() text = "\n".join(pre_timestamp_text).strip()
if text: if text:
entries.append((0.0, text)) entries.append((0.0, text))
@@ -287,7 +292,7 @@ def parse_ass_file(file_path):
List of tuples: [(start_time_seconds, end_time_seconds, text), ...] List of tuples: [(start_time_seconds, end_time_seconds, text), ...]
""" """
encoding = detect_encoding(file_path) encoding = detect_encoding(file_path)
with open(file_path, 'r', encoding=encoding, errors='replace') as f: with open(file_path, "r", encoding=encoding, errors="replace") as f:
lines = f.readlines() lines = f.readlines()
subtitles = [] subtitles = []
@@ -297,50 +302,56 @@ def parse_ass_file(file_path):
for line in lines: for line in lines:
line = line.strip() line = line.strip()
if line.startswith('[Events]'): if line.startswith("[Events]"):
in_events = True in_events = True
continue continue
if line.startswith('[') and in_events: if line.startswith("[") and in_events:
# New section, stop processing # New section, stop processing
break break
if in_events and line.startswith('Format:'): if in_events and line.startswith("Format:"):
# Parse format line to know column positions # Parse format line to know column positions
parts = line.split(':', 1)[1].strip().split(',') parts = line.split(":", 1)[1].strip().split(",")
for i, part in enumerate(parts): for i, part in enumerate(parts):
format_indices[part.strip().lower()] = i format_indices[part.strip().lower()] = i
continue continue
if in_events and (line.startswith('Dialogue:') or line.startswith('Comment:')): if in_events and (line.startswith("Dialogue:") or line.startswith("Comment:")):
if line.startswith('Comment:'): if line.startswith("Comment:"):
continue # Skip comments continue # Skip comments
parts = line.split(':', 1)[1].strip().split(',', len(format_indices) - 1) parts = line.split(":", 1)[1].strip().split(",", len(format_indices) - 1)
if 'start' in format_indices and 'end' in format_indices and 'text' in format_indices: if (
start_str = parts[format_indices['start']].strip() "start" in format_indices
end_str = parts[format_indices['end']].strip() and "end" in format_indices
text = parts[format_indices['text']].strip() and "text" in format_indices
):
start_str = parts[format_indices["start"]].strip()
end_str = parts[format_indices["end"]].strip()
text = parts[format_indices["text"]].strip()
# Convert timestamp to seconds (ASS format: H:MM:SS.CS where CS is centiseconds) # Convert timestamp to seconds (ASS format: H:MM:SS.CS where CS is centiseconds)
def ass_time_to_seconds(t): def ass_time_to_seconds(t):
parts = t.split(':') parts = t.split(":")
if len(parts) == 3: if len(parts) == 3:
h, m, s = parts h, m, s = parts
s_parts = s.split('.') s_parts = s.split(".")
seconds = float(s_parts[0]) seconds = float(s_parts[0])
centiseconds = float(s_parts[1]) if len(s_parts) > 1 else 0 centiseconds = float(s_parts[1]) if len(s_parts) > 1 else 0
return int(h) * 3600 + int(m) * 60 + seconds + centiseconds / 100.0 return (
int(h) * 3600 + int(m) * 60 + seconds + centiseconds / 100.0
)
return 0 return 0
start_sec = ass_time_to_seconds(start_str) start_sec = ass_time_to_seconds(start_str)
end_sec = ass_time_to_seconds(end_str) end_sec = ass_time_to_seconds(end_str)
# Clean text of ASS styling tags # Clean text of ASS styling tags
text = re.sub(r'\{[^}]+\}', '', text) # Remove {tags} text = re.sub(r"\{[^}]+\}", "", text) # Remove {tags}
text = re.sub(r'\\N', '\n', text) # Convert \N to newline text = re.sub(r"\\N", "\n", text) # Convert \N to newline
text = re.sub(r'\\n', '\n', text) # Convert \n to newline text = re.sub(r"\\n", "\n", text) # Convert \n to newline
# Remove chapter markers and metadata tags # Remove chapter markers and metadata tags
text = clean_subtitle_text(text) text = clean_subtitle_text(text)
@@ -418,6 +429,7 @@ def sanitize_name_for_os(name, is_folder=True):
class CountdownDialog(QDialog): class CountdownDialog(QDialog):
"""Base dialog with auto-accept countdown functionality""" """Base dialog with auto-accept countdown functionality"""
def __init__(self, title, countdown_seconds, parent=None): def __init__(self, title, countdown_seconds, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle(title) self.setWindowTitle(title)
@@ -435,7 +447,9 @@ class CountdownDialog(QDialog):
def add_countdown_and_buttons(self): def add_countdown_and_buttons(self):
"""Add countdown label and OK button - call this after adding custom content""" """Add countdown label and OK button - call this after adding custom content"""
self.countdown_label = QLabel(f"Auto-accepting in {self.countdown_seconds} seconds...") self.countdown_label = QLabel(
f"Auto-accepting in {self.countdown_seconds} seconds..."
)
self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};") self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};")
self.layout.addWidget(self.countdown_label) self.layout.addWidget(self.countdown_label)
@@ -450,7 +464,9 @@ class CountdownDialog(QDialog):
def _on_timer_tick(self): def _on_timer_tick(self):
self.countdown_seconds -= 1 self.countdown_seconds -= 1
if self.countdown_seconds > 0: if self.countdown_seconds > 0:
self.countdown_label.setText(f"Auto-accepting in {self.countdown_seconds} seconds...") self.countdown_label.setText(
f"Auto-accepting in {self.countdown_seconds} seconds..."
)
else: else:
self._timer.stop() self._timer.stop()
self._button_box.accepted.emit() self._button_box.accepted.emit()
@@ -469,7 +485,9 @@ class ChapterOptionsDialog(CountdownDialog):
def __init__(self, chapter_count, parent=None): def __init__(self, chapter_count, parent=None):
super().__init__("Chapter Options", CHAPTER_OPTIONS_COUNTDOWN, parent) super().__init__("Chapter Options", CHAPTER_OPTIONS_COUNTDOWN, parent)
self.layout.addWidget(QLabel(f"Detected {chapter_count} chapters in the text file.")) self.layout.addWidget(
QLabel(f"Detected {chapter_count} chapters in the text file.")
)
self.layout.addWidget(QLabel("How would you like to process these chapters?")) self.layout.addWidget(QLabel("How would you like to process these chapters?"))
self.save_separately_checkbox = QCheckBox("Save each chapter separately") self.save_separately_checkbox = QCheckBox("Save each chapter separately")
@@ -478,7 +496,9 @@ class ChapterOptionsDialog(CountdownDialog):
self.save_separately_checkbox.setChecked(False) self.save_separately_checkbox.setChecked(False)
self.merge_at_end_checkbox.setChecked(True) self.merge_at_end_checkbox.setChecked(True)
self.save_separately_checkbox.stateChanged.connect(self.update_merge_checkbox_state) self.save_separately_checkbox.stateChanged.connect(
self.update_merge_checkbox_state
)
self.layout.addWidget(self.save_separately_checkbox) self.layout.addWidget(self.save_separately_checkbox)
self.layout.addWidget(self.merge_at_end_checkbox) self.layout.addWidget(self.merge_at_end_checkbox)
@@ -492,7 +512,8 @@ class ChapterOptionsDialog(CountdownDialog):
def get_options(self): def get_options(self):
return { return {
"save_chapters_separately": self.save_separately_checkbox.isChecked(), "save_chapters_separately": self.save_separately_checkbox.isChecked(),
"merge_chapters_at_end": self.merge_at_end_checkbox.isChecked() and self.merge_at_end_checkbox.isEnabled(), "merge_chapters_at_end": self.merge_at_end_checkbox.isChecked()
and self.merge_at_end_checkbox.isEnabled(),
} }
@@ -507,9 +528,13 @@ class TimestampDetectionDialog(QDialog):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.addWidget(QLabel("This file contains timestamps in HH:MM:SS format.")) layout.addWidget(QLabel("This file contains timestamps in HH:MM:SS format."))
layout.addWidget(QLabel("Do you want to use these timestamps for precise audio timing?")) layout.addWidget(
QLabel("Do you want to use these timestamps for precise audio timing?")
)
yes_label = QLabel("• Yes: Generate audio that matches each timestamp (subtitle mode will be ignored)") yes_label = QLabel(
"• Yes: Generate audio that matches each timestamp (subtitle mode will be ignored)"
)
yes_label.setStyleSheet(f"color: {COLORS['BLUE_BORDER_HOVER']};") yes_label.setStyleSheet(f"color: {COLORS['BLUE_BORDER_HOVER']};")
layout.addWidget(yes_label) layout.addWidget(yes_label)
@@ -518,7 +543,9 @@ class TimestampDetectionDialog(QDialog):
layout.addWidget(no_label) layout.addWidget(no_label)
# Countdown label # Countdown label
self.countdown_label = QLabel(f"Auto-accepting in {self.countdown_seconds} seconds...") self.countdown_label = QLabel(
f"Auto-accepting in {self.countdown_seconds} seconds..."
)
self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};") self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};")
layout.addWidget(self.countdown_label) layout.addWidget(self.countdown_label)
@@ -539,7 +566,9 @@ class TimestampDetectionDialog(QDialog):
def _on_timer_tick(self): def _on_timer_tick(self):
self.countdown_seconds -= 1 self.countdown_seconds -= 1
if self.countdown_seconds > 0: if self.countdown_seconds > 0:
self.countdown_label.setText(f"Auto-accepting in {self.countdown_seconds} seconds...") self.countdown_label.setText(
f"Auto-accepting in {self.countdown_seconds} seconds..."
)
else: else:
self._timer.stop() self._timer.stop()
self._set_result(True) self._set_result(True)
@@ -565,18 +594,18 @@ class ConversionThread(QThread):
# Languages that should not use split pattern (better handled by Kokoro internally) # Languages that should not use split pattern (better handled by Kokoro internally)
# These languages have different text segmentation rules (no spaces, character-based, etc.) # These languages have different text segmentation rules (no spaces, character-based, etc.)
NO_SPLIT_LANGUAGES = {'z', 'j'} # Chinese, Japanese NO_SPLIT_LANGUAGES = {"z", "j"} # Chinese, Japanese
# Language-specific punctuation patterns for subtitle splitting # Language-specific punctuation patterns for subtitle splitting
LANGUAGE_PUNCTUATION = { LANGUAGE_PUNCTUATION = {
'z': { "z": {
'sentence': r"[。!?]", # Chinese: period, exclamation, question "sentence": r"[。!?]", # Chinese: period, exclamation, question
'comma': r"[。!?、,]", # Chinese: includes enumeration comma and comma "comma": r"[。!?、,]", # Chinese: includes enumeration comma and comma
},
"j": {
"sentence": r"[。!?]", # Japanese: period, exclamation, question
"comma": r"[。!?、,]", # Japanese: includes enumeration comma and comma
}, },
'j': {
'sentence': r"[。!?]", # Japanese: period, exclamation, question
'comma': r"[。!?、,]", # Japanese: includes enumeration comma and comma
}
} }
def __init__( def __init__(
@@ -627,7 +656,9 @@ class ConversionThread(QThread):
self.max_subtitle_words = 50 # Default value, will be overridden from GUI self.max_subtitle_words = 50 # Default value, will be overridden from GUI
self.silence_duration = 2.0 # Default value, will be overridden from GUI self.silence_duration = 2.0 # Default value, will be overridden from GUI
# Set split pattern based on language - some languages handle splitting better internally # Set split pattern based on language - some languages handle splitting better internally
self.split_pattern = None if lang_code in self.NO_SPLIT_LANGUAGES else self.DEFAULT_SPLIT_PATTERN self.split_pattern = (
None if lang_code in self.NO_SPLIT_LANGUAGES else self.DEFAULT_SPLIT_PATTERN
)
def _stream_audio_in_chunks( def _stream_audio_in_chunks(
self, segments, process_func, progress_prefix="Processing" self, segments, process_func, progress_prefix="Processing"
@@ -741,15 +772,19 @@ class ConversionThread(QThread):
is_subtitle_input = False is_subtitle_input = False
if not self.is_direct_text and self.file_name: if not self.is_direct_text and self.file_name:
file_ext = os.path.splitext(self.file_name)[1].lower() file_ext = os.path.splitext(self.file_name)[1].lower()
if file_ext in ['.srt', '.ass', '.vtt']: if file_ext in [".srt", ".ass", ".vtt"]:
is_subtitle_input = True is_subtitle_input = True
# Display subtitle-specific options if processing subtitle file # Display subtitle-specific options if processing subtitle file
if is_subtitle_input: if is_subtitle_input:
if getattr(self, 'use_silent_gaps', False): if getattr(self, "use_silent_gaps", False):
self.log_updated.emit("- Use silent gaps: Yes") self.log_updated.emit("- Use silent gaps: Yes")
speed_method = getattr(self, 'subtitle_speed_method', 'tts') speed_method = getattr(self, "subtitle_speed_method", "tts")
method_label = "TTS Regeneration" if speed_method == "tts" else "FFmpeg Time-stretch" method_label = (
"TTS Regeneration"
if speed_method == "tts"
else "FFmpeg Time-stretch"
)
self.log_updated.emit(f"- Speed adjustment method: {method_label}") self.log_updated.emit(f"- Speed adjustment method: {method_label}")
# Display save_chapters_separately flag if it's set # Display save_chapters_separately flag if it's set
@@ -802,23 +837,25 @@ class ConversionThread(QThread):
is_timestamp_text = False is_timestamp_text = False
if not self.is_direct_text and self.file_name: if not self.is_direct_text and self.file_name:
file_ext = os.path.splitext(self.file_name)[1].lower() file_ext = os.path.splitext(self.file_name)[1].lower()
if file_ext in ['.srt', '.ass', '.vtt']: if file_ext in [".srt", ".ass", ".vtt"]:
is_subtitle_file = True is_subtitle_file = True
self.log_updated.emit(f"\nDetected subtitle file format: {file_ext}") self.log_updated.emit(
elif file_ext == '.txt' and detect_timestamps_in_text(self.file_name): f"\nDetected subtitle file format: {file_ext}"
)
elif file_ext == ".txt" and detect_timestamps_in_text(self.file_name):
is_timestamp_text = True is_timestamp_text = True
self.log_updated.emit("\nDetected timestamps in text file") self.log_updated.emit("\nDetected timestamps in text file")
# Signal to ask user (-1 indicates timestamp detection) # Signal to ask user (-1 indicates timestamp detection)
self.chapters_detected.emit(-1) self.chapters_detected.emit(-1)
# Wait for user response # Wait for user response
while not hasattr(self, '_timestamp_response'): while not hasattr(self, "_timestamp_response"):
if self.cancel_requested: if self.cancel_requested:
self.conversion_finished.emit("Cancelled", None) self.conversion_finished.emit("Cancelled", None)
return return
time.sleep(0.1) time.sleep(0.1)
if not self._timestamp_response: if not self._timestamp_response:
is_timestamp_text = False is_timestamp_text = False
delattr(self, '_timestamp_response') delattr(self, "_timestamp_response")
# Process subtitle files separately # Process subtitle files separately
if is_subtitle_file or is_timestamp_text: if is_subtitle_file or is_timestamp_text:
@@ -1650,16 +1687,18 @@ class ConversionThread(QThread):
subtitles = parse_timestamp_text_file(self.file_name) subtitles = parse_timestamp_text_file(self.file_name)
else: else:
file_ext = os.path.splitext(self.file_name)[1].lower() file_ext = os.path.splitext(self.file_name)[1].lower()
if file_ext == '.srt': if file_ext == ".srt":
subtitles = parse_srt_file(self.file_name) subtitles = parse_srt_file(self.file_name)
elif file_ext == '.vtt': elif file_ext == ".vtt":
subtitles = parse_vtt_file(self.file_name) subtitles = parse_vtt_file(self.file_name)
else: else:
subtitles = parse_ass_file(self.file_name) subtitles = parse_ass_file(self.file_name)
if not subtitles: if not subtitles:
self.log_updated.emit(("No valid subtitle entries found.", "red")) self.log_updated.emit(("No valid subtitle entries found.", "red"))
self.conversion_finished.emit(("No subtitle entries to process.", "red"), None) self.conversion_finished.emit(
("No subtitle entries to process.", "red"), None
)
return return
self.log_updated.emit(f"\nFound {len(subtitles)} subtitle entries") self.log_updated.emit(f"\nFound {len(subtitles)} subtitle entries")
@@ -1667,12 +1706,20 @@ class ConversionThread(QThread):
# Setup output paths # Setup output paths
base_name = os.path.splitext(os.path.basename(base_path))[0] base_name = os.path.splitext(os.path.basename(base_path))[0]
sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True) sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True)
parent_dir = (user_desktop_dir() if self.save_option == "Save to Desktop" parent_dir = (
else os.path.dirname(base_path) if self.save_option == "Save next to input file" user_desktop_dir()
else self.output_folder or os.getcwd()) if self.save_option == "Save to Desktop"
else (
os.path.dirname(base_path)
if self.save_option == "Save next to input file"
else self.output_folder or os.getcwd()
)
)
if not os.path.exists(parent_dir): if not os.path.exists(parent_dir):
self.log_updated.emit((f"Output folder does not exist: {parent_dir}", "red")) self.log_updated.emit(
(f"Output folder does not exist: {parent_dir}", "red")
)
return return
# Find unique filename # Find unique filename
@@ -1680,23 +1727,46 @@ class ConversionThread(QThread):
allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS) allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS)
while True: while True:
suffix = f"_{counter}" if counter > 1 else "" suffix = f"_{counter}" if counter > 1 else ""
if not any(os.path.splitext(f)[0] == f"{sanitized_base_name}{suffix}" if not any(
os.path.splitext(f)[0] == f"{sanitized_base_name}{suffix}"
and os.path.splitext(f)[1][1:].lower() in allowed_exts and os.path.splitext(f)[1][1:].lower() in allowed_exts
for f in os.listdir(parent_dir)): for f in os.listdir(parent_dir)
):
break break
counter += 1 counter += 1
base_filepath_no_ext = os.path.join(parent_dir, f"{sanitized_base_name}{suffix}") base_filepath_no_ext = os.path.join(
parent_dir, f"{sanitized_base_name}{suffix}"
)
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
rate = 24000 rate = 24000
# Setup audio output # Setup audio output
merged_out_file, ffmpeg_proc = None, None merged_out_file, ffmpeg_proc = None, None
if self.output_format in ["wav", "mp3", "flac"]: if self.output_format in ["wav", "mp3", "flac"]:
merged_out_file = sf.SoundFile(merged_out_path, "w", samplerate=rate, channels=1, format=self.output_format) merged_out_file = sf.SoundFile(
merged_out_path,
"w",
samplerate=rate,
channels=1,
format=self.output_format,
)
else: else:
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
cmd = ["ffmpeg", "-y", "-thread_queue_size", "32768", "-f", "f32le", "-ar", str(rate), "-ac", "1", "-i", "pipe:0"] cmd = [
"ffmpeg",
"-y",
"-thread_queue_size",
"32768",
"-f",
"f32le",
"-ar",
str(rate),
"-ac",
"1",
"-i",
"pipe:0",
]
if self.output_format == "m4b": if self.output_format == "m4b":
metadata_options, cover_path = ( metadata_options, cover_path = (
self._extract_and_add_metadata_tags_to_ffmpeg_cmd() self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
@@ -1730,7 +1800,9 @@ class ConversionThread(QThread):
elif self.output_format == "opus": elif self.output_format == "opus":
cmd.extend(["-c:a", "libopus", "-b:a", "24000"]) cmd.extend(["-c:a", "libopus", "-b:a", "24000"])
else: else:
self.log_updated.emit((f"Unsupported output format: {self.output_format}", "red")) self.log_updated.emit(
(f"Unsupported output format: {self.output_format}", "red")
)
return return
cmd.append(merged_out_path) cmd.append(merged_out_path)
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False) ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
@@ -1744,23 +1816,42 @@ class ConversionThread(QThread):
if "ass" in subtitle_format: if "ass" in subtitle_format:
# Write ASS header # Write ASS header
subtitle_file.write("[Script Info]\nTitle: Generated by Abogen\nScriptType: v4.00+\n\n") subtitle_file.write(
"[Script Info]\nTitle: Generated by Abogen\nScriptType: v4.00+\n\n"
)
if self.subtitle_mode == "Sentence + Highlighting": if self.subtitle_mode == "Sentence + Highlighting":
subtitle_file.write("[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n") subtitle_file.write(
subtitle_file.write("Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n") "[V4+ Styles]\nFormat: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
subtitle_file.write("[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n") )
subtitle_file.write(
"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n"
)
subtitle_file.write(
"[Events]\nFormat: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
)
is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow") is_narrow = subtitle_format in ("ass_narrow", "ass_centered_narrow")
is_centered = subtitle_format in ("ass_centered_wide", "ass_centered_narrow") is_centered = subtitle_format in (
"ass_centered_wide",
"ass_centered_narrow",
)
margin = "90" if is_narrow else "" margin = "90" if is_narrow else ""
alignment = "{\\an5}" if is_centered else "" alignment = "{\\an5}" if is_centered else ""
# Load voice # Load voice
loaded_voice = get_new_voice(tts, self.voice, self.use_gpu) if "*" in self.voice else self.voice loaded_voice = (
get_new_voice(tts, self.voice, self.use_gpu)
if "*" in self.voice
else self.voice
)
# Calculate initial audio buffer size from timed subtitles only # Calculate initial audio buffer size from timed subtitles only
max_end_time = max((end for _, end, _ in subtitles if end is not None), default=0) max_end_time = max(
audio_buffer = self.np.zeros(int(max_end_time * rate) + rate, dtype="float32") (end for _, end, _ in subtitles if end is not None), default=0
)
audio_buffer = self.np.zeros(
int(max_end_time * rate) + rate, dtype="float32"
)
# Process each subtitle and mix into buffer # Process each subtitle and mix into buffer
self.etr_start_time = time.time() self.etr_start_time = time.time()
@@ -1768,7 +1859,8 @@ class ConversionThread(QThread):
for idx, (start_time, end_time, text) in enumerate(subtitles, 1): for idx, (start_time, end_time, text) in enumerate(subtitles, 1):
if self.cancel_requested: if self.cancel_requested:
if subtitle_file: subtitle_file.close() if subtitle_file:
subtitle_file.close()
self.conversion_finished.emit("Cancelled", None) self.conversion_finished.emit("Cancelled", None)
return return
@@ -1776,35 +1868,72 @@ class ConversionThread(QThread):
replace_nl = getattr(self, "replace_single_newlines", False) replace_nl = getattr(self, "replace_single_newlines", False)
processed_text = text.replace("\n", " ") if replace_nl else text processed_text = text.replace("\n", " ") if replace_nl else text
use_gaps = getattr(self, "use_silent_gaps", False) use_gaps = getattr(self, "use_silent_gaps", False)
next_start = subtitles[idx][0] if (use_gaps and idx < len(subtitles)) else float("inf") next_start = (
subtitles[idx][0]
if (use_gaps and idx < len(subtitles))
else float("inf")
)
subtitle_duration = None if end_time is None else end_time - start_time subtitle_duration = None if end_time is None else end_time - start_time
h1, m1, s1 = int(start_time // 3600), int(start_time % 3600 // 60), int(start_time % 60) h1, m1, s1 = (
int(start_time // 3600),
int(start_time % 3600 // 60),
int(start_time % 60),
)
ms1 = int((start_time - int(start_time)) * 1000) ms1 = int((start_time - int(start_time)) * 1000)
is_last = use_gaps and idx == len(subtitles) is_last = use_gaps and idx == len(subtitles)
if is_last: if is_last:
time_str = f"{h1:02d}:{m1:02d}:{s1:02d}" + (f",{ms1:03d}" if ms1 > 0 else "") + " - AUTO" time_str = (
f"{h1:02d}:{m1:02d}:{s1:02d}"
+ (f",{ms1:03d}" if ms1 > 0 else "")
+ " - AUTO"
)
else: else:
h2, m2, s2 = int(end_time // 3600), int(end_time % 3600 // 60), int(end_time % 60) h2, m2, s2 = (
int(end_time // 3600),
int(end_time % 3600 // 60),
int(end_time % 60),
)
ms2 = int((end_time - int(end_time)) * 1000) ms2 = int((end_time - int(end_time)) * 1000)
time_str = (f"{h1:02d}:{m1:02d}:{s1:02d}" + (f",{ms1:03d}" if ms1 > 0 else "") + time_str = (
" - " + f"{h2:02d}:{m2:02d}:{s2:02d}" + (f",{ms2:03d}" if ms2 > 0 else "")) f"{h1:02d}:{m1:02d}:{s1:02d}"
self.log_updated.emit(f"\n[{idx}/{len(subtitles)}] {time_str}: {processed_text}") + (f",{ms1:03d}" if ms1 > 0 else "")
+ " - "
+ f"{h2:02d}:{m2:02d}:{s2:02d}"
+ (f",{ms2:03d}" if ms2 > 0 else "")
)
self.log_updated.emit(
f"\n[{idx}/{len(subtitles)}] {time_str}: {processed_text}"
)
# Generate TTS audio # Generate TTS audio
tts_results = [r for r in tts(processed_text, voice=loaded_voice, speed=self.speed, split_pattern=None) if not self.cancel_requested] tts_results = [
r
for r in tts(
processed_text,
voice=loaded_voice,
speed=self.speed,
split_pattern=None,
)
if not self.cancel_requested
]
audio_chunks = [r.audio for r in tts_results] audio_chunks = [r.audio for r in tts_results]
if self.cancel_requested: if self.cancel_requested:
if subtitle_file: subtitle_file.close() if subtitle_file:
subtitle_file.close()
self.conversion_finished.emit("Cancelled", None) self.conversion_finished.emit("Cancelled", None)
return return
# Concatenate audio and determine duration # Concatenate audio and determine duration
full_audio = ( full_audio = (
self.np.concatenate([a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks]) self.np.concatenate(
[a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks]
)
if audio_chunks if audio_chunks
else self.np.zeros(int((subtitle_duration or 0) * rate), dtype="float32") else self.np.zeros(
int((subtitle_duration or 0) * rate), dtype="float32"
)
) )
audio_duration = len(full_audio) / rate audio_duration = len(full_audio) / rate
@@ -1817,38 +1946,91 @@ class ConversionThread(QThread):
end_time = start_time + audio_duration end_time = start_time + audio_duration
# Speed up if needed # Speed up if needed
speedup_threshold = next_start - start_time if use_gaps else subtitle_duration speedup_threshold = (
next_start - start_time if use_gaps else subtitle_duration
)
if audio_duration > speedup_threshold: if audio_duration > speedup_threshold:
speed_factor = audio_duration / speedup_threshold speed_factor = audio_duration / speedup_threshold
if getattr(self, 'subtitle_speed_method', 'tts') == "ffmpeg": if getattr(self, "subtitle_speed_method", "tts") == "ffmpeg":
# FFmpeg time-stretch (faster processing) # FFmpeg time-stretch (faster processing)
self.log_updated.emit((f" -> FFmpeg time-stretch: {speed_factor:.2f}x", "grey")) self.log_updated.emit(
(f" -> FFmpeg time-stretch: {speed_factor:.2f}x", "grey")
)
static_ffmpeg.add_paths() static_ffmpeg.add_paths()
num_stages = max(1, int(self.np.ceil(self.np.log(speed_factor) / self.np.log(2.0)))) num_stages = max(
1,
int(
self.np.ceil(
self.np.log(speed_factor) / self.np.log(2.0)
)
),
)
tempo = speed_factor ** (1.0 / num_stages) tempo = speed_factor ** (1.0 / num_stages)
filter_str = ",".join([f"atempo={tempo:.6f}"] * num_stages) filter_str = ",".join([f"atempo={tempo:.6f}"] * num_stages)
speed_proc = subprocess.Popen( speed_proc = subprocess.Popen(
["ffmpeg", "-y", "-f", "f32le", "-ar", str(rate), "-ac", "1", "-i", "pipe:0", [
"-filter:a", filter_str, "-f", "f32le", "-ar", str(rate), "-ac", "1", "pipe:1"], "ffmpeg",
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE "-y",
"-f",
"f32le",
"-ar",
str(rate),
"-ac",
"1",
"-i",
"pipe:0",
"-filter:a",
filter_str,
"-f",
"f32le",
"-ar",
str(rate),
"-ac",
"1",
"pipe:1",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
full_audio = self.np.frombuffer(
speed_proc.communicate(input=full_audio.tobytes())[0],
dtype="float32",
) )
full_audio = self.np.frombuffer(speed_proc.communicate(input=full_audio.tobytes())[0], dtype="float32")
audio_duration = len(full_audio) / rate audio_duration = len(full_audio) / rate
else: else:
# TTS regeneration (better quality) # TTS regeneration (better quality)
new_speed = self.speed * speed_factor new_speed = self.speed * speed_factor
self.log_updated.emit((f" -> Regenerating at {new_speed:.2f}x speed", "grey")) self.log_updated.emit(
(f" -> Regenerating at {new_speed:.2f}x speed", "grey")
)
tts_results = [r for r in tts(processed_text, voice=loaded_voice, speed=new_speed, split_pattern=None) if not self.cancel_requested] tts_results = [
r
for r in tts(
processed_text,
voice=loaded_voice,
speed=new_speed,
split_pattern=None,
)
if not self.cancel_requested
]
audio_chunks = [r.audio for r in tts_results] audio_chunks = [r.audio for r in tts_results]
full_audio = ( full_audio = (
self.np.concatenate([a.numpy() if hasattr(a, "numpy") else a for a in audio_chunks]) self.np.concatenate(
[
a.numpy() if hasattr(a, "numpy") else a
for a in audio_chunks
]
)
if audio_chunks if audio_chunks
else self.np.zeros(int(subtitle_duration * rate), dtype="float32") else self.np.zeros(
int(subtitle_duration * rate), dtype="float32"
)
) )
audio_duration = len(full_audio) / rate audio_duration = len(full_audio) / rate
@@ -1863,7 +2045,14 @@ class ConversionThread(QThread):
# Pad or trim to subtitle duration # Pad or trim to subtitle duration
target_samples = int(subtitle_duration * rate) target_samples = int(subtitle_duration * rate)
if len(full_audio) < target_samples: if len(full_audio) < target_samples:
full_audio = self.np.concatenate([full_audio, self.np.zeros(target_samples - len(full_audio), dtype="float32")]) full_audio = self.np.concatenate(
[
full_audio,
self.np.zeros(
target_samples - len(full_audio), dtype="float32"
),
]
)
elif len(full_audio) > target_samples: elif len(full_audio) > target_samples:
full_audio = full_audio[:target_samples] full_audio = full_audio[:target_samples]
@@ -1872,7 +2061,14 @@ class ConversionThread(QThread):
end_sample = start_sample + len(full_audio) end_sample = start_sample + len(full_audio)
if end_sample > len(audio_buffer): if end_sample > len(audio_buffer):
# Extend buffer if needed # Extend buffer if needed
audio_buffer = self.np.concatenate([audio_buffer, self.np.zeros(end_sample - len(audio_buffer), dtype="float32")]) audio_buffer = self.np.concatenate(
[
audio_buffer,
self.np.zeros(
end_sample - len(audio_buffer), dtype="float32"
),
]
)
# Mix (add) the audio - this handles overlaps by combining them # Mix (add) the audio - this handles overlaps by combining them
audio_buffer[start_sample:end_sample] += full_audio audio_buffer[start_sample:end_sample] += full_audio
@@ -1880,24 +2076,41 @@ class ConversionThread(QThread):
# Write subtitle # Write subtitle
if subtitle_file: if subtitle_file:
if "ass" in subtitle_format: if "ass" in subtitle_format:
effect = "karaoke" if self.subtitle_mode == "Sentence + Highlighting" else "" effect = (
ass_text = processed_text if replace_nl else processed_text.replace('\n', '\\N') "karaoke"
subtitle_file.write(f"Dialogue: 0,{self._ass_time(start_time)},{self._ass_time(end_time)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n") if self.subtitle_mode == "Sentence + Highlighting"
else ""
)
ass_text = (
processed_text
if replace_nl
else processed_text.replace("\n", "\\N")
)
subtitle_file.write(
f"Dialogue: 0,{self._ass_time(start_time)},{self._ass_time(end_time)},Default,,{margin},{margin},0,{effect},{alignment}{ass_text}\n"
)
else: else:
subtitle_file.write(f"{srt_index}\n{self._srt_time(start_time)} --> {self._srt_time(end_time)}\n{processed_text}\n\n") subtitle_file.write(
f"{srt_index}\n{self._srt_time(start_time)} --> {self._srt_time(end_time)}\n{processed_text}\n\n"
)
srt_index += 1 srt_index += 1
# Update progress # Update progress
percent = min(int(idx / len(subtitles) * 100), 99) percent = min(int(idx / len(subtitles) * 100), 99)
elapsed = time.time() - self.etr_start_time elapsed = time.time() - self.etr_start_time
etr_str = ("Processing..." if elapsed <= 0.5 etr_str = (
else f"{int(elapsed*(len(subtitles)-idx)/idx)//3600:02d}:{(int(elapsed*(len(subtitles)-idx)/idx)%3600)//60:02d}:{int(elapsed*(len(subtitles)-idx)/idx)%60:02d}") "Processing..."
if elapsed <= 0.5
else f"{int(elapsed*(len(subtitles)-idx)/idx)//3600:02d}:{(int(elapsed*(len(subtitles)-idx)/idx)%3600)//60:02d}:{int(elapsed*(len(subtitles)-idx)/idx)%60:02d}"
)
self.progress_updated.emit(percent, etr_str) self.progress_updated.emit(percent, etr_str)
# Normalize audio buffer to prevent clipping from mixed overlaps # Normalize audio buffer to prevent clipping from mixed overlaps
max_amplitude = self.np.abs(audio_buffer).max() max_amplitude = self.np.abs(audio_buffer).max()
if max_amplitude > 1.0: if max_amplitude > 1.0:
self.log_updated.emit(f"\n -> Normalizing audio (peak: {max_amplitude:.2f})") self.log_updated.emit(
f"\n -> Normalizing audio (peak: {max_amplitude:.2f})"
)
audio_buffer = audio_buffer / max_amplitude audio_buffer = audio_buffer / max_amplitude
# Write the complete audio buffer # Write the complete audio buffer
@@ -1910,20 +2123,25 @@ class ConversionThread(QThread):
ffmpeg_proc.stdin.close() ffmpeg_proc.stdin.close()
ffmpeg_proc.wait() ffmpeg_proc.wait()
if subtitle_file: subtitle_file.close() if subtitle_file:
subtitle_file.close()
self.progress_updated.emit(100, "00:00:00") self.progress_updated.emit(100, "00:00:00")
result_msg = (f"\nAudiobook saved to: {merged_out_path}" + result_msg = f"\nAudiobook saved to: {merged_out_path}" + (
(f"\n\nSubtitle saved to: {subtitle_path}" if subtitle_path else "")) f"\n\nSubtitle saved to: {subtitle_path}" if subtitle_path else ""
)
self.conversion_finished.emit((result_msg, "green"), merged_out_path) self.conversion_finished.emit((result_msg, "green"), merged_out_path)
except Exception as e: except Exception as e:
try: try:
if "ffmpeg_proc" in locals() and ffmpeg_proc: if "ffmpeg_proc" in locals() and ffmpeg_proc:
ffmpeg_proc.stdin.close(); ffmpeg_proc.terminate(); ffmpeg_proc.wait() ffmpeg_proc.stdin.close()
ffmpeg_proc.terminate()
ffmpeg_proc.wait()
if "subtitle_file" in locals() and subtitle_file: if "subtitle_file" in locals() and subtitle_file:
subtitle_file.close() subtitle_file.close()
except: pass except:
pass
self.log_updated.emit((f"Error processing subtitle file: {str(e)}", "red")) self.log_updated.emit((f"Error processing subtitle file: {str(e)}", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None) self.conversion_finished.emit(("Audio generation failed.", "red"), None)
@@ -2068,7 +2286,11 @@ class ConversionThread(QThread):
# Sentence-based processing with karaoke highlighting # Sentence-based processing with karaoke highlighting
# Use language-specific punctuation for CJK languages (without comma) # Use language-specific punctuation for CJK languages (without comma)
lang_punct = self.LANGUAGE_PUNCTUATION.get(self.lang_code, {}) lang_punct = self.LANGUAGE_PUNCTUATION.get(self.lang_code, {})
separator = lang_punct.get('sentence', r"[.!?]") if isinstance(lang_punct, dict) else r"[.!?]" separator = (
lang_punct.get("sentence", r"[.!?]")
if isinstance(lang_punct, dict)
else r"[.!?]"
)
current_sentence = [] current_sentence = []
word_count = 0 word_count = 0
@@ -2131,11 +2353,19 @@ class ConversionThread(QThread):
elif self.subtitle_mode == "Sentence": elif self.subtitle_mode == "Sentence":
# Use language-specific punctuation for CJK languages (without comma) # Use language-specific punctuation for CJK languages (without comma)
lang_punct = self.LANGUAGE_PUNCTUATION.get(self.lang_code, {}) lang_punct = self.LANGUAGE_PUNCTUATION.get(self.lang_code, {})
separator = lang_punct.get('sentence', r"[.!?]") if isinstance(lang_punct, dict) else r"[.!?]" separator = (
lang_punct.get("sentence", r"[.!?]")
if isinstance(lang_punct, dict)
else r"[.!?]"
)
else: # Sentence + Comma else: # Sentence + Comma
# Use language-specific punctuation for CJK languages (with comma) # Use language-specific punctuation for CJK languages (with comma)
lang_punct = self.LANGUAGE_PUNCTUATION.get(self.lang_code, {}) lang_punct = self.LANGUAGE_PUNCTUATION.get(self.lang_code, {})
separator = lang_punct.get('comma', r"[.!?,]") if isinstance(lang_punct, dict) else r"[.!?,]" separator = (
lang_punct.get("comma", r"[.!?,]")
if isinstance(lang_punct, dict)
else r"[.!?,]"
)
current_sentence = [] current_sentence = []
word_count = 0 word_count = 0
+32 -17
View File
@@ -391,7 +391,9 @@ class InputBox(QLabel):
if hasattr(window, "subtitle_combo"): if hasattr(window, "subtitle_combo"):
# Only enable if language supports it # Only enable if language supports it
current_lang = getattr(window, "lang_code", "a") current_lang = getattr(window, "lang_code", "a")
window.subtitle_combo.setEnabled(current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION) window.subtitle_combo.setEnabled(
current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
)
if hasattr(window, "replace_newlines_combo"): if hasattr(window, "replace_newlines_combo"):
window.replace_newlines_combo.setEnabled(True) window.replace_newlines_combo.setEnabled(True)
@@ -502,7 +504,9 @@ class InputBox(QLabel):
) )
event.acceptProposedAction() event.acceptProposedAction()
else: else:
self.set_error("Please drop a .txt, .epub, .pdf, .md, .srt, .ass, or .vtt file.") self.set_error(
"Please drop a .txt, .epub, .pdf, .md, .srt, .ass, or .vtt file."
)
event.ignore() event.ignore()
else: else:
event.ignore() event.ignore()
@@ -1318,7 +1322,10 @@ class abogen(QWidget):
return return
try: try:
file_path, _ = QFileDialog.getOpenFileName( file_path, _ = QFileDialog.getOpenFileName(
self, "Select File", "", "Supported Files (*.txt *.epub *.pdf *.md *.srt *.ass *.vtt)" self,
"Select File",
"",
"Supported Files (*.txt *.epub *.pdf *.md *.srt *.ass *.vtt)",
) )
if not file_path: if not file_path:
return return
@@ -1934,9 +1941,7 @@ class abogen(QWidget):
self.replace_single_newlines = getattr( self.replace_single_newlines = getattr(
queued_item, "replace_single_newlines", False queued_item, "replace_single_newlines", False
) )
self.use_silent_gaps = getattr( self.use_silent_gaps = getattr(queued_item, "use_silent_gaps", False)
queued_item, "use_silent_gaps", False
)
# Restore the original file path for save location # Restore the original file path for save location
self.displayed_file_path = ( self.displayed_file_path = (
queued_item.save_base_path or queued_item.file_name queued_item.save_base_path or queued_item.file_name
@@ -2083,9 +2088,7 @@ class abogen(QWidget):
self.replace_single_newlines self.replace_single_newlines
) )
# Pass use_silent_gaps setting # Pass use_silent_gaps setting
self.conversion_thread.use_silent_gaps = ( self.conversion_thread.use_silent_gaps = self.use_silent_gaps
self.use_silent_gaps
)
# Pass subtitle_speed_method setting # Pass subtitle_speed_method setting
self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method
# Pass separate_chapters_format setting # Pass separate_chapters_format setting
@@ -2820,7 +2823,10 @@ class abogen(QWidget):
# Dialog always accepts (Yes or No), never cancels the conversion # Dialog always accepts (Yes or No), never cancels the conversion
dialog.exec() dialog.exec()
treat_as_subtitle = dialog.use_timestamps() treat_as_subtitle = dialog.use_timestamps()
if hasattr(self, "conversion_thread") and self.conversion_thread.isRunning(): if (
hasattr(self, "conversion_thread")
and self.conversion_thread.isRunning()
):
self.conversion_thread.set_timestamp_response(treat_as_subtitle) self.conversion_thread.set_timestamp_response(treat_as_subtitle)
return return
@@ -2832,7 +2838,10 @@ class abogen(QWidget):
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
options = dialog.get_options() options = dialog.get_options()
if hasattr(self, "conversion_thread") and self.conversion_thread.isRunning(): if (
hasattr(self, "conversion_thread")
and self.conversion_thread.isRunning()
):
self.conversion_thread.set_chapter_options(options) self.conversion_thread.set_chapter_options(options)
else: else:
self.cancel_conversion() self.cancel_conversion()
@@ -3113,12 +3122,16 @@ class abogen(QWidget):
speed_method_group = QActionGroup(self) speed_method_group = QActionGroup(self)
speed_method_group.setExclusive(True) speed_method_group.setExclusive(True)
for method, label in [("tts", "TTS Regeneration (better quality)"), for method, label in [
("ffmpeg", "FFmpeg Time-stretch (better speed)")]: ("tts", "TTS Regeneration (better quality)"),
("ffmpeg", "FFmpeg Time-stretch (better speed)"),
]:
action = QAction(label, speed_method_menu) action = QAction(label, speed_method_menu)
action.setCheckable(True) action.setCheckable(True)
action.setChecked(self.subtitle_speed_method == method) action.setChecked(self.subtitle_speed_method == method)
action.triggered.connect(lambda checked, m=method: self.toggle_subtitle_speed_method(m)) action.triggered.connect(
lambda checked, m=method: self.toggle_subtitle_speed_method(m)
)
speed_method_group.addAction(action) speed_method_group.addAction(action)
speed_method_menu.addAction(action) speed_method_menu.addAction(action)
@@ -3165,15 +3178,17 @@ class abogen(QWidget):
def toggle_use_silent_gaps(self, enabled): def toggle_use_silent_gaps(self, enabled):
# Show confirmation dialog with explanation # Show confirmation dialog with explanation
action = "enable" if enabled else "disable" action = "enable" if enabled else "disable"
message = ("When enabled, allows speech to continue naturally into the silent periods between subtitles, " message = (
"When enabled, allows speech to continue naturally into the silent periods between subtitles, "
"preventing unnecessary audio speed-up based on subtitle end timestamps.\n\nWhen disabled, ensures strict subtitle timing where " "preventing unnecessary audio speed-up based on subtitle end timestamps.\n\nWhen disabled, ensures strict subtitle timing where "
f"audio ends exactly when the subtitle ends.\n\nDo you want to {action} this option?") f"audio ends exactly when the subtitle ends.\n\nDo you want to {action} this option?"
)
reply = QMessageBox.question( reply = QMessageBox.question(
self, self,
"Use Silent Gaps Between Subtitles", "Use Silent Gaps Between Subtitles",
message, message,
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
) )
if reply == QMessageBox.StandardButton.Ok: if reply == QMessageBox.StandardButton.Ok:
+6 -1
View File
@@ -8,9 +8,14 @@ import signal
if platform.system() == "Windows": if platform.system() == "Windows":
import ctypes import ctypes
from importlib.util import find_spec from importlib.util import find_spec
try: try:
if (spec := find_spec("torch")) and spec.origin and os.path.exists( if (
(spec := find_spec("torch"))
and spec.origin
and os.path.exists(
dll_path := os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll") dll_path := os.path.join(os.path.dirname(spec.origin), "lib", "c10.dll")
)
): ):
ctypes.CDLL(os.path.normpath(dll_path)) ctypes.CDLL(os.path.normpath(dll_path))
except Exception: except Exception:
+18 -7
View File
@@ -88,7 +88,10 @@ class DroppableQueueListWidget(QListWidget):
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
for url in event.mimeData().urls(): for url in event.mimeData().urls():
file_path = url.toLocalFile().lower() file_path = url.toLocalFile().lower()
if url.isLocalFile() and (file_path.endswith(".txt") or file_path.endswith((".srt", ".ass", ".vtt"))): if url.isLocalFile() and (
file_path.endswith(".txt")
or file_path.endswith((".srt", ".ass", ".vtt"))
):
self.drag_overlay.resize(self.size()) self.drag_overlay.resize(self.size())
self.drag_overlay.setVisible(True) self.drag_overlay.setVisible(True)
event.acceptProposedAction() event.acceptProposedAction()
@@ -100,7 +103,10 @@ class DroppableQueueListWidget(QListWidget):
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
for url in event.mimeData().urls(): for url in event.mimeData().urls():
file_path = url.toLocalFile().lower() file_path = url.toLocalFile().lower()
if url.isLocalFile() and (file_path.endswith(".txt") or file_path.endswith((".srt", ".ass", ".vtt"))): if url.isLocalFile() and (
file_path.endswith(".txt")
or file_path.endswith((".srt", ".ass", ".vtt"))
):
event.acceptProposedAction() event.acceptProposedAction()
return return
event.ignore() event.ignore()
@@ -115,7 +121,11 @@ class DroppableQueueListWidget(QListWidget):
file_paths = [ file_paths = [
url.toLocalFile() url.toLocalFile()
for url in event.mimeData().urls() for url in event.mimeData().urls()
if url.isLocalFile() and (url.toLocalFile().lower().endswith(".txt") or url.toLocalFile().lower().endswith((".srt", ".ass", ".vtt"))) if url.isLocalFile()
and (
url.toLocalFile().lower().endswith(".txt")
or url.toLocalFile().lower().endswith((".srt", ".ass", ".vtt"))
)
] ]
if file_paths: if file_paths:
self.parent_dialog.add_files_from_paths(file_paths) self.parent_dialog.add_files_from_paths(file_paths)
@@ -408,9 +418,7 @@ class QueueManager(QDialog):
parent, "replace_single_newlines", False parent, "replace_single_newlines", False
) )
# use_silent_gaps # use_silent_gaps
attrs["use_silent_gaps"] = getattr( attrs["use_silent_gaps"] = getattr(parent, "use_silent_gaps", False)
parent, "use_silent_gaps", False
)
# subtitle_speed_method # subtitle_speed_method
attrs["subtitle_speed_method"] = getattr( attrs["subtitle_speed_method"] = getattr(
parent, "subtitle_speed_method", "tts" parent, "subtitle_speed_method", "tts"
@@ -527,7 +535,10 @@ class QueueManager(QDialog):
# Allow .txt, .srt, .ass, and .vtt files # Allow .txt, .srt, .ass, and .vtt files
files, _ = QFileDialog.getOpenFileNames( files, _ = QFileDialog.getOpenFileNames(
self, "Select text or subtitle files", "", "Supported Files (*.txt *.srt *.ass *.vtt)" self,
"Select text or subtitle files",
"",
"Supported Files (*.txt *.srt *.ass *.vtt)",
) )
if not files: if not files:
return return