mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Reformat using black
This commit is contained in:
@@ -377,6 +377,7 @@ class HandlerDialog(QDialog):
|
||||
|
||||
# Include replace_single_newlines in cache key since it affects text cleaning
|
||||
from abogen.utils import load_config
|
||||
|
||||
cfg = load_config()
|
||||
replace_single_newlines = cfg.get("replace_single_newlines", False)
|
||||
|
||||
@@ -2240,6 +2241,7 @@ class HandlerDialog(QDialog):
|
||||
if metadata.get("cover_image"):
|
||||
try:
|
||||
import uuid
|
||||
|
||||
cache_dir = get_user_cache_path()
|
||||
cover_path = os.path.join(cache_dir, f"cover_{uuid.uuid4()}.jpg")
|
||||
cover_path = os.path.normpath(cover_path)
|
||||
|
||||
+1
-3
@@ -2,9 +2,7 @@ from abogen.utils import get_version
|
||||
|
||||
# Program Information
|
||||
PROGRAM_NAME = "abogen"
|
||||
PROGRAM_DESCRIPTION = (
|
||||
"Generate audiobooks from EPUBs, PDFs, text and subtitles with synchronized captions."
|
||||
)
|
||||
PROGRAM_DESCRIPTION = "Generate audiobooks from EPUBs, PDFs, text and subtitles with synchronized captions."
|
||||
GITHUB_URL = "https://github.com/denizsafak/abogen"
|
||||
VERSION = get_version()
|
||||
|
||||
|
||||
+378
-148
@@ -32,9 +32,9 @@ import platform
|
||||
def clean_subtitle_text(text):
|
||||
"""Remove chapter markers and metadata tags from subtitle text."""
|
||||
# Remove metadata tags
|
||||
text = re.sub(r'<<METADATA_[^:]+:[^>]*>>', '', text)
|
||||
text = re.sub(r"<<METADATA_[^:]+:[^>]*>>", "", text)
|
||||
# Remove chapter markers
|
||||
text = re.sub(r'<<CHAPTER_MARKER:[^>]*>>', '', text)
|
||||
text = re.sub(r"<<CHAPTER_MARKER:[^>]*>>", "", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
@@ -49,43 +49,46 @@ def parse_srt_file(file_path):
|
||||
List of tuples: [(start_time_seconds, end_time_seconds, text), ...]
|
||||
"""
|
||||
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()
|
||||
|
||||
# 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 = []
|
||||
for block in blocks:
|
||||
if not block.strip():
|
||||
continue
|
||||
|
||||
lines = block.strip().split('\n')
|
||||
lines = block.strip().split("\n")
|
||||
if len(lines) < 3:
|
||||
continue
|
||||
|
||||
# First line is index, second line is timestamp, rest is text
|
||||
try:
|
||||
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:
|
||||
continue
|
||||
|
||||
start_str = match.group(1)
|
||||
end_str = match.group(2)
|
||||
text = '\n'.join(lines[2:])
|
||||
text = "\n".join(lines[2:])
|
||||
|
||||
# Convert timestamp to seconds
|
||||
def time_to_seconds(t):
|
||||
h, m, s_ms = t.split(':')
|
||||
s, ms = s_ms.split(',')
|
||||
h, m, s_ms = t.split(":")
|
||||
s, ms = s_ms.split(",")
|
||||
return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000.0
|
||||
|
||||
start_sec = time_to_seconds(start_str)
|
||||
end_sec = time_to_seconds(end_str)
|
||||
|
||||
# Clean text of any styling tags
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
# Remove chapter markers and metadata tags
|
||||
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), ...]
|
||||
"""
|
||||
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()
|
||||
|
||||
# Remove WEBVTT header and any style/note blocks
|
||||
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'NOTE\s*\n.*?(?=\n\n|$)', '', content, flags=re.DOTALL)
|
||||
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"NOTE\s*\n.*?(?=\n\n|$)", "", content, flags=re.DOTALL)
|
||||
|
||||
# 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 = []
|
||||
for block in blocks:
|
||||
if not block.strip():
|
||||
continue
|
||||
|
||||
lines = block.strip().split('\n')
|
||||
lines = block.strip().split("\n")
|
||||
if len(lines) < 2:
|
||||
continue
|
||||
|
||||
@@ -133,10 +136,10 @@ def parse_vtt_file(file_path):
|
||||
text_start_idx = 0
|
||||
|
||||
# Check if first line is timestamp
|
||||
if '-->' in lines[0]:
|
||||
if "-->" in lines[0]:
|
||||
timestamp_line = lines[0]
|
||||
text_start_idx = 1
|
||||
elif len(lines) > 1 and '-->' in lines[1]:
|
||||
elif len(lines) > 1 and "-->" in lines[1]:
|
||||
timestamp_line = lines[1]
|
||||
text_start_idx = 2
|
||||
else:
|
||||
@@ -144,24 +147,24 @@ def parse_vtt_file(file_path):
|
||||
|
||||
try:
|
||||
# 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:
|
||||
continue
|
||||
|
||||
start_str = match.group(1)
|
||||
end_str = match.group(2)
|
||||
text = '\n'.join(lines[text_start_idx:])
|
||||
text = "\n".join(lines[text_start_idx:])
|
||||
|
||||
# Convert timestamp to seconds
|
||||
def time_to_seconds(t):
|
||||
parts = t.split(':')
|
||||
parts = t.split(":")
|
||||
if len(parts) == 3: # HH:MM:SS.mmm
|
||||
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
|
||||
elif len(parts) == 2: # MM:SS.mmm
|
||||
m, s = parts
|
||||
s, ms = s.split('.')
|
||||
s, ms = s.split(".")
|
||||
return int(m) * 60 + int(s) + int(ms) / 1000.0
|
||||
return 0
|
||||
|
||||
@@ -169,8 +172,8 @@ def parse_vtt_file(file_path):
|
||||
end_sec = time_to_seconds(end_str)
|
||||
|
||||
# Clean text of any styling tags and cue settings
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r'{[^}]+}', '', text) # Remove voice tags
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = re.sub(r"{[^}]+}", "", text) # Remove voice tags
|
||||
# Remove chapter markers and metadata tags
|
||||
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."""
|
||||
try:
|
||||
encoding = detect_encoding(file_path)
|
||||
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
|
||||
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
|
||||
|
||||
# Count lines that are ONLY timestamps (no other text)
|
||||
# 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))
|
||||
|
||||
# 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.
|
||||
Supports HH:MM:SS or HH:MM:SS,ms format. Returns time in seconds as float."""
|
||||
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()
|
||||
|
||||
# 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})?)$'
|
||||
lines = content.split('\n')
|
||||
pattern = r"^(\d{1,2}:\d{2}:\d{2}(?:,\d{1,3})?)$"
|
||||
lines = content.split("\n")
|
||||
|
||||
def parse_time(time_str):
|
||||
"""Convert HH:MM:SS or HH:MM:SS,ms to seconds as float."""
|
||||
if ',' in time_str:
|
||||
time_part, ms_part = time_str.split(',')
|
||||
parts = time_part.split(':')
|
||||
if "," in time_str:
|
||||
time_part, ms_part = time_str.split(",")
|
||||
parts = time_part.split(":")
|
||||
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
|
||||
else:
|
||||
parts = time_str.split(':')
|
||||
parts = time_str.split(":")
|
||||
return float(int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]))
|
||||
|
||||
entries = []
|
||||
@@ -233,12 +238,12 @@ def parse_timestamp_text_file(file_path):
|
||||
if match:
|
||||
# Save previous entry
|
||||
if current_time is not None and current_text:
|
||||
text = '\n'.join(current_text).strip()
|
||||
text = "\n".join(current_text).strip()
|
||||
if text:
|
||||
entries.append((current_time, text))
|
||||
elif current_time is None and pre_timestamp_text:
|
||||
# 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:
|
||||
entries.append((0.0, text))
|
||||
pre_timestamp_text = []
|
||||
@@ -255,12 +260,12 @@ def parse_timestamp_text_file(file_path):
|
||||
|
||||
# Save last entry
|
||||
if current_time is not None and current_text:
|
||||
text = '\n'.join(current_text).strip()
|
||||
text = "\n".join(current_text).strip()
|
||||
if text:
|
||||
entries.append((current_time, text))
|
||||
elif not entries and pre_timestamp_text:
|
||||
# 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:
|
||||
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), ...]
|
||||
"""
|
||||
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()
|
||||
|
||||
subtitles = []
|
||||
@@ -297,50 +302,56 @@ def parse_ass_file(file_path):
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
|
||||
if line.startswith('[Events]'):
|
||||
if line.startswith("[Events]"):
|
||||
in_events = True
|
||||
continue
|
||||
|
||||
if line.startswith('[') and in_events:
|
||||
if line.startswith("[") and in_events:
|
||||
# New section, stop processing
|
||||
break
|
||||
|
||||
if in_events and line.startswith('Format:'):
|
||||
if in_events and line.startswith("Format:"):
|
||||
# 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):
|
||||
format_indices[part.strip().lower()] = i
|
||||
continue
|
||||
|
||||
if in_events and (line.startswith('Dialogue:') or line.startswith('Comment:')):
|
||||
if line.startswith('Comment:'):
|
||||
if in_events and (line.startswith("Dialogue:") or line.startswith("Comment:")):
|
||||
if line.startswith("Comment:"):
|
||||
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:
|
||||
start_str = parts[format_indices['start']].strip()
|
||||
end_str = parts[format_indices['end']].strip()
|
||||
text = parts[format_indices['text']].strip()
|
||||
if (
|
||||
"start" in format_indices
|
||||
and "end" in format_indices
|
||||
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)
|
||||
def ass_time_to_seconds(t):
|
||||
parts = t.split(':')
|
||||
parts = t.split(":")
|
||||
if len(parts) == 3:
|
||||
h, m, s = parts
|
||||
s_parts = s.split('.')
|
||||
s_parts = s.split(".")
|
||||
seconds = float(s_parts[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
|
||||
|
||||
start_sec = ass_time_to_seconds(start_str)
|
||||
end_sec = ass_time_to_seconds(end_str)
|
||||
|
||||
# Clean text of ASS styling 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"\{[^}]+\}", "", 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
|
||||
# Remove chapter markers and metadata tags
|
||||
text = clean_subtitle_text(text)
|
||||
|
||||
@@ -418,6 +429,7 @@ def sanitize_name_for_os(name, is_folder=True):
|
||||
|
||||
class CountdownDialog(QDialog):
|
||||
"""Base dialog with auto-accept countdown functionality"""
|
||||
|
||||
def __init__(self, title, countdown_seconds, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(title)
|
||||
@@ -435,7 +447,9 @@ class CountdownDialog(QDialog):
|
||||
|
||||
def add_countdown_and_buttons(self):
|
||||
"""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.layout.addWidget(self.countdown_label)
|
||||
|
||||
@@ -450,7 +464,9 @@ class CountdownDialog(QDialog):
|
||||
def _on_timer_tick(self):
|
||||
self.countdown_seconds -= 1
|
||||
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:
|
||||
self._timer.stop()
|
||||
self._button_box.accepted.emit()
|
||||
@@ -469,7 +485,9 @@ class ChapterOptionsDialog(CountdownDialog):
|
||||
def __init__(self, chapter_count, parent=None):
|
||||
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.save_separately_checkbox = QCheckBox("Save each chapter separately")
|
||||
@@ -478,7 +496,9 @@ class ChapterOptionsDialog(CountdownDialog):
|
||||
self.save_separately_checkbox.setChecked(False)
|
||||
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.merge_at_end_checkbox)
|
||||
@@ -492,7 +512,8 @@ class ChapterOptionsDialog(CountdownDialog):
|
||||
def get_options(self):
|
||||
return {
|
||||
"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.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']};")
|
||||
layout.addWidget(yes_label)
|
||||
|
||||
@@ -518,7 +543,9 @@ class TimestampDetectionDialog(QDialog):
|
||||
layout.addWidget(no_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']};")
|
||||
layout.addWidget(self.countdown_label)
|
||||
|
||||
@@ -539,7 +566,9 @@ class TimestampDetectionDialog(QDialog):
|
||||
def _on_timer_tick(self):
|
||||
self.countdown_seconds -= 1
|
||||
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:
|
||||
self._timer.stop()
|
||||
self._set_result(True)
|
||||
@@ -565,18 +594,18 @@ class ConversionThread(QThread):
|
||||
|
||||
# Languages that should not use split pattern (better handled by Kokoro internally)
|
||||
# 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_PUNCTUATION = {
|
||||
'z': {
|
||||
'sentence': r"[。!?]", # Chinese: period, exclamation, question
|
||||
'comma': r"[。!?、,]", # Chinese: includes enumeration comma and comma
|
||||
"z": {
|
||||
"sentence": r"[。!?]", # Chinese: period, exclamation, question
|
||||
"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__(
|
||||
@@ -627,7 +656,9 @@ class ConversionThread(QThread):
|
||||
self.max_subtitle_words = 50 # 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
|
||||
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(
|
||||
self, segments, process_func, progress_prefix="Processing"
|
||||
@@ -741,15 +772,19 @@ class ConversionThread(QThread):
|
||||
is_subtitle_input = False
|
||||
if not self.is_direct_text and self.file_name:
|
||||
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
|
||||
|
||||
# Display subtitle-specific options if processing subtitle file
|
||||
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")
|
||||
speed_method = getattr(self, 'subtitle_speed_method', 'tts')
|
||||
method_label = "TTS Regeneration" if speed_method == "tts" else "FFmpeg Time-stretch"
|
||||
speed_method = getattr(self, "subtitle_speed_method", "tts")
|
||||
method_label = (
|
||||
"TTS Regeneration"
|
||||
if speed_method == "tts"
|
||||
else "FFmpeg Time-stretch"
|
||||
)
|
||||
self.log_updated.emit(f"- Speed adjustment method: {method_label}")
|
||||
|
||||
# Display save_chapters_separately flag if it's set
|
||||
@@ -802,23 +837,25 @@ class ConversionThread(QThread):
|
||||
is_timestamp_text = False
|
||||
if not self.is_direct_text and self.file_name:
|
||||
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
|
||||
self.log_updated.emit(f"\nDetected subtitle file format: {file_ext}")
|
||||
elif file_ext == '.txt' and detect_timestamps_in_text(self.file_name):
|
||||
self.log_updated.emit(
|
||||
f"\nDetected subtitle file format: {file_ext}"
|
||||
)
|
||||
elif file_ext == ".txt" and detect_timestamps_in_text(self.file_name):
|
||||
is_timestamp_text = True
|
||||
self.log_updated.emit("\nDetected timestamps in text file")
|
||||
# Signal to ask user (-1 indicates timestamp detection)
|
||||
self.chapters_detected.emit(-1)
|
||||
# Wait for user response
|
||||
while not hasattr(self, '_timestamp_response'):
|
||||
while not hasattr(self, "_timestamp_response"):
|
||||
if self.cancel_requested:
|
||||
self.conversion_finished.emit("Cancelled", None)
|
||||
return
|
||||
time.sleep(0.1)
|
||||
if not self._timestamp_response:
|
||||
is_timestamp_text = False
|
||||
delattr(self, '_timestamp_response')
|
||||
delattr(self, "_timestamp_response")
|
||||
|
||||
# Process subtitle files separately
|
||||
if is_subtitle_file or is_timestamp_text:
|
||||
@@ -1650,16 +1687,18 @@ class ConversionThread(QThread):
|
||||
subtitles = parse_timestamp_text_file(self.file_name)
|
||||
else:
|
||||
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)
|
||||
elif file_ext == '.vtt':
|
||||
elif file_ext == ".vtt":
|
||||
subtitles = parse_vtt_file(self.file_name)
|
||||
else:
|
||||
subtitles = parse_ass_file(self.file_name)
|
||||
|
||||
if not subtitles:
|
||||
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
|
||||
|
||||
self.log_updated.emit(f"\nFound {len(subtitles)} subtitle entries")
|
||||
@@ -1667,12 +1706,20 @@ class ConversionThread(QThread):
|
||||
# Setup output paths
|
||||
base_name = os.path.splitext(os.path.basename(base_path))[0]
|
||||
sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True)
|
||||
parent_dir = (user_desktop_dir() 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())
|
||||
parent_dir = (
|
||||
user_desktop_dir()
|
||||
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):
|
||||
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
|
||||
|
||||
# Find unique filename
|
||||
@@ -1680,23 +1727,46 @@ class ConversionThread(QThread):
|
||||
allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS)
|
||||
while True:
|
||||
suffix = f"_{counter}" if counter > 1 else ""
|
||||
if not any(os.path.splitext(f)[0] == f"{sanitized_base_name}{suffix}"
|
||||
and os.path.splitext(f)[1][1:].lower() in allowed_exts
|
||||
for f in os.listdir(parent_dir)):
|
||||
if not any(
|
||||
os.path.splitext(f)[0] == f"{sanitized_base_name}{suffix}"
|
||||
and os.path.splitext(f)[1][1:].lower() in allowed_exts
|
||||
for f in os.listdir(parent_dir)
|
||||
):
|
||||
break
|
||||
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}"
|
||||
rate = 24000
|
||||
|
||||
# Setup audio output
|
||||
merged_out_file, ffmpeg_proc = None, None
|
||||
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:
|
||||
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":
|
||||
metadata_options, cover_path = (
|
||||
self._extract_and_add_metadata_tags_to_ffmpeg_cmd()
|
||||
@@ -1730,7 +1800,9 @@ class ConversionThread(QThread):
|
||||
elif self.output_format == "opus":
|
||||
cmd.extend(["-c:a", "libopus", "-b:a", "24000"])
|
||||
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
|
||||
cmd.append(merged_out_path)
|
||||
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
|
||||
@@ -1744,23 +1816,42 @@ class ConversionThread(QThread):
|
||||
|
||||
if "ass" in subtitle_format:
|
||||
# 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":
|
||||
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("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")
|
||||
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(
|
||||
"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_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 ""
|
||||
alignment = "{\\an5}" if is_centered else ""
|
||||
|
||||
# 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
|
||||
max_end_time = max((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")
|
||||
max_end_time = max(
|
||||
(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
|
||||
self.etr_start_time = time.time()
|
||||
@@ -1768,7 +1859,8 @@ class ConversionThread(QThread):
|
||||
|
||||
for idx, (start_time, end_time, text) in enumerate(subtitles, 1):
|
||||
if self.cancel_requested:
|
||||
if subtitle_file: subtitle_file.close()
|
||||
if subtitle_file:
|
||||
subtitle_file.close()
|
||||
self.conversion_finished.emit("Cancelled", None)
|
||||
return
|
||||
|
||||
@@ -1776,35 +1868,72 @@ class ConversionThread(QThread):
|
||||
replace_nl = getattr(self, "replace_single_newlines", False)
|
||||
processed_text = text.replace("\n", " ") if replace_nl else text
|
||||
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
|
||||
|
||||
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)
|
||||
is_last = use_gaps and idx == len(subtitles)
|
||||
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:
|
||||
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)
|
||||
time_str = (f"{h1:02d}:{m1:02d}:{s1:02d}" + (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}")
|
||||
time_str = (
|
||||
f"{h1:02d}:{m1:02d}:{s1:02d}"
|
||||
+ (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
|
||||
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]
|
||||
|
||||
if self.cancel_requested:
|
||||
if subtitle_file: subtitle_file.close()
|
||||
if subtitle_file:
|
||||
subtitle_file.close()
|
||||
self.conversion_finished.emit("Cancelled", None)
|
||||
return
|
||||
|
||||
# Concatenate audio and determine duration
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1817,38 +1946,91 @@ class ConversionThread(QThread):
|
||||
end_time = start_time + audio_duration
|
||||
|
||||
# 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:
|
||||
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)
|
||||
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()
|
||||
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)
|
||||
filter_str = ",".join([f"atempo={tempo:.6f}"] * num_stages)
|
||||
|
||||
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"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
[
|
||||
"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",
|
||||
],
|
||||
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
|
||||
else:
|
||||
# TTS regeneration (better quality)
|
||||
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]
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -1863,7 +2045,14 @@ class ConversionThread(QThread):
|
||||
# Pad or trim to subtitle duration
|
||||
target_samples = int(subtitle_duration * rate)
|
||||
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:
|
||||
full_audio = full_audio[:target_samples]
|
||||
|
||||
@@ -1872,7 +2061,14 @@ class ConversionThread(QThread):
|
||||
end_sample = start_sample + len(full_audio)
|
||||
if end_sample > len(audio_buffer):
|
||||
# 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
|
||||
audio_buffer[start_sample:end_sample] += full_audio
|
||||
@@ -1880,24 +2076,41 @@ class ConversionThread(QThread):
|
||||
# Write subtitle
|
||||
if subtitle_file:
|
||||
if "ass" in subtitle_format:
|
||||
effect = "karaoke" 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")
|
||||
effect = (
|
||||
"karaoke"
|
||||
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:
|
||||
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
|
||||
|
||||
# Update progress
|
||||
percent = min(int(idx / len(subtitles) * 100), 99)
|
||||
elapsed = time.time() - self.etr_start_time
|
||||
etr_str = ("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}")
|
||||
etr_str = (
|
||||
"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)
|
||||
|
||||
# Normalize audio buffer to prevent clipping from mixed overlaps
|
||||
max_amplitude = self.np.abs(audio_buffer).max()
|
||||
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
|
||||
|
||||
# Write the complete audio buffer
|
||||
@@ -1910,20 +2123,25 @@ class ConversionThread(QThread):
|
||||
ffmpeg_proc.stdin.close()
|
||||
ffmpeg_proc.wait()
|
||||
|
||||
if subtitle_file: subtitle_file.close()
|
||||
if subtitle_file:
|
||||
subtitle_file.close()
|
||||
|
||||
self.progress_updated.emit(100, "00:00:00")
|
||||
result_msg = (f"\nAudiobook saved to: {merged_out_path}" +
|
||||
(f"\n\nSubtitle saved to: {subtitle_path}" if subtitle_path else ""))
|
||||
result_msg = f"\nAudiobook saved to: {merged_out_path}" + (
|
||||
f"\n\nSubtitle saved to: {subtitle_path}" if subtitle_path else ""
|
||||
)
|
||||
self.conversion_finished.emit((result_msg, "green"), merged_out_path)
|
||||
|
||||
except Exception as e:
|
||||
try:
|
||||
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:
|
||||
subtitle_file.close()
|
||||
except: pass
|
||||
except:
|
||||
pass
|
||||
self.log_updated.emit((f"Error processing subtitle file: {str(e)}", "red"))
|
||||
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
|
||||
|
||||
@@ -2068,7 +2286,11 @@ class ConversionThread(QThread):
|
||||
# Sentence-based processing with karaoke highlighting
|
||||
# Use language-specific punctuation for CJK languages (without comma)
|
||||
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 = []
|
||||
word_count = 0
|
||||
|
||||
@@ -2131,11 +2353,19 @@ class ConversionThread(QThread):
|
||||
elif self.subtitle_mode == "Sentence":
|
||||
# Use language-specific punctuation for CJK languages (without comma)
|
||||
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
|
||||
# Use language-specific punctuation for CJK languages (with comma)
|
||||
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 = []
|
||||
word_count = 0
|
||||
|
||||
|
||||
+33
-18
@@ -391,7 +391,9 @@ class InputBox(QLabel):
|
||||
if hasattr(window, "subtitle_combo"):
|
||||
# Only enable if language supports it
|
||||
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"):
|
||||
window.replace_newlines_combo.setEnabled(True)
|
||||
|
||||
@@ -502,7 +504,9 @@ class InputBox(QLabel):
|
||||
)
|
||||
event.acceptProposedAction()
|
||||
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()
|
||||
else:
|
||||
event.ignore()
|
||||
@@ -1318,7 +1322,10 @@ class abogen(QWidget):
|
||||
return
|
||||
try:
|
||||
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:
|
||||
return
|
||||
@@ -1934,9 +1941,7 @@ class abogen(QWidget):
|
||||
self.replace_single_newlines = getattr(
|
||||
queued_item, "replace_single_newlines", False
|
||||
)
|
||||
self.use_silent_gaps = getattr(
|
||||
queued_item, "use_silent_gaps", False
|
||||
)
|
||||
self.use_silent_gaps = getattr(queued_item, "use_silent_gaps", False)
|
||||
# Restore the original file path for save location
|
||||
self.displayed_file_path = (
|
||||
queued_item.save_base_path or queued_item.file_name
|
||||
@@ -2083,9 +2088,7 @@ class abogen(QWidget):
|
||||
self.replace_single_newlines
|
||||
)
|
||||
# Pass use_silent_gaps setting
|
||||
self.conversion_thread.use_silent_gaps = (
|
||||
self.use_silent_gaps
|
||||
)
|
||||
self.conversion_thread.use_silent_gaps = self.use_silent_gaps
|
||||
# Pass subtitle_speed_method setting
|
||||
self.conversion_thread.subtitle_speed_method = self.subtitle_speed_method
|
||||
# Pass separate_chapters_format setting
|
||||
@@ -2820,7 +2823,10 @@ class abogen(QWidget):
|
||||
# Dialog always accepts (Yes or No), never cancels the conversion
|
||||
dialog.exec()
|
||||
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)
|
||||
return
|
||||
|
||||
@@ -2832,7 +2838,10 @@ class abogen(QWidget):
|
||||
|
||||
if dialog.exec() == QDialog.DialogCode.Accepted:
|
||||
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)
|
||||
else:
|
||||
self.cancel_conversion()
|
||||
@@ -3113,12 +3122,16 @@ class abogen(QWidget):
|
||||
speed_method_group = QActionGroup(self)
|
||||
speed_method_group.setExclusive(True)
|
||||
|
||||
for method, label in [("tts", "TTS Regeneration (better quality)"),
|
||||
("ffmpeg", "FFmpeg Time-stretch (better speed)")]:
|
||||
for method, label in [
|
||||
("tts", "TTS Regeneration (better quality)"),
|
||||
("ffmpeg", "FFmpeg Time-stretch (better speed)"),
|
||||
]:
|
||||
action = QAction(label, speed_method_menu)
|
||||
action.setCheckable(True)
|
||||
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_menu.addAction(action)
|
||||
|
||||
@@ -3165,15 +3178,17 @@ class abogen(QWidget):
|
||||
def toggle_use_silent_gaps(self, enabled):
|
||||
# Show confirmation dialog with explanation
|
||||
action = "enable" if enabled else "disable"
|
||||
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 "
|
||||
f"audio ends exactly when the subtitle ends.\n\nDo you want to {action} this option?")
|
||||
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 "
|
||||
f"audio ends exactly when the subtitle ends.\n\nDo you want to {action} this option?"
|
||||
)
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Use Silent Gaps Between Subtitles",
|
||||
message,
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
|
||||
)
|
||||
|
||||
if reply == QMessageBox.StandardButton.Ok:
|
||||
|
||||
+7
-2
@@ -8,9 +8,14 @@ import signal
|
||||
if platform.system() == "Windows":
|
||||
import ctypes
|
||||
from importlib.util import find_spec
|
||||
|
||||
try:
|
||||
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")
|
||||
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")
|
||||
)
|
||||
):
|
||||
ctypes.CDLL(os.path.normpath(dll_path))
|
||||
except Exception:
|
||||
|
||||
@@ -88,7 +88,10 @@ class DroppableQueueListWidget(QListWidget):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
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.setVisible(True)
|
||||
event.acceptProposedAction()
|
||||
@@ -100,7 +103,10 @@ class DroppableQueueListWidget(QListWidget):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
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()
|
||||
return
|
||||
event.ignore()
|
||||
@@ -115,7 +121,11 @@ class DroppableQueueListWidget(QListWidget):
|
||||
file_paths = [
|
||||
url.toLocalFile()
|
||||
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:
|
||||
self.parent_dialog.add_files_from_paths(file_paths)
|
||||
@@ -408,9 +418,7 @@ class QueueManager(QDialog):
|
||||
parent, "replace_single_newlines", False
|
||||
)
|
||||
# use_silent_gaps
|
||||
attrs["use_silent_gaps"] = getattr(
|
||||
parent, "use_silent_gaps", False
|
||||
)
|
||||
attrs["use_silent_gaps"] = getattr(parent, "use_silent_gaps", False)
|
||||
# subtitle_speed_method
|
||||
attrs["subtitle_speed_method"] = getattr(
|
||||
parent, "subtitle_speed_method", "tts"
|
||||
@@ -527,7 +535,10 @@ class QueueManager(QDialog):
|
||||
|
||||
# Allow .txt, .srt, .ass, and .vtt files
|
||||
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:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user