Add subtitle file voicing feature, improvements in code and documentation

This commit is contained in:
Deniz Şafak
2025-11-18 04:37:15 +03:00
parent ad8a6e520e
commit 6bb6fcb406
8 changed files with 969 additions and 110 deletions
+5 -1
View File
@@ -3,7 +3,7 @@ from abogen.utils import get_version
# Program Information
PROGRAM_NAME = "abogen"
PROGRAM_DESCRIPTION = (
"Generate audiobooks from EPUBs, PDFs and text with synchronized captions."
"Generate audiobooks from EPUBs, PDFs, text and subtitles with synchronized captions."
)
GITHUB_URL = "https://github.com/denizsafak/abogen"
VERSION = get_version()
@@ -44,6 +44,7 @@ SUPPORTED_SOUND_FORMATS = [
SUPPORTED_SUBTITLE_FORMATS = [
"srt",
"ass",
"vtt",
]
# Supported input formats
@@ -51,6 +52,9 @@ SUPPORTED_INPUT_FORMATS = [
"epub",
"pdf",
"txt",
"srt",
"ass",
"vtt",
]
# Supported languages for subtitle generation
+771 -80
View File
@@ -29,6 +29,327 @@ import subprocess
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)
# Remove chapter markers
text = re.sub(r'<<CHAPTER_MARKER:[^>]*>>', '', text)
return text.strip()
def parse_srt_file(file_path):
"""
Parse an SRT subtitle file and return a list of subtitle entries.
Args:
file_path: Path to the SRT file
Returns:
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:
content = f.read()
# Split by double newlines to get individual subtitle blocks
blocks = re.split(r'\n\s*\n', content.strip())
subtitles = []
for block in blocks:
if not block.strip():
continue
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)
if not match:
continue
start_str = match.group(1)
end_str = match.group(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(',')
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)
# Remove chapter markers and metadata tags
text = clean_subtitle_text(text)
if text: # Only add non-empty subtitles
subtitles.append((start_sec, end_sec, text))
except (ValueError, IndexError):
continue
return subtitles
def parse_vtt_file(file_path):
"""
Parse a VTT (WebVTT) subtitle file and return a list of subtitle entries.
Args:
file_path: Path to the VTT file
Returns:
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:
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)
# Split by double newlines to get individual subtitle blocks
blocks = re.split(r'\n\s*\n', content.strip())
subtitles = []
for block in blocks:
if not block.strip():
continue
lines = block.strip().split('\n')
if len(lines) < 2:
continue
# VTT can have optional identifier on first line, timestamp on second or first
timestamp_line = None
text_start_idx = 0
# Check if first line is timestamp
if '-->' in lines[0]:
timestamp_line = lines[0]
text_start_idx = 1
elif len(lines) > 1 and '-->' in lines[1]:
timestamp_line = lines[1]
text_start_idx = 2
else:
continue
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)
if not match:
continue
start_str = match.group(1)
end_str = match.group(2)
text = '\n'.join(lines[text_start_idx:])
# Convert timestamp to seconds
def time_to_seconds(t):
parts = t.split(':')
if len(parts) == 3: # HH:MM:SS.mmm
h, m, s = parts
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('.')
return int(m) * 60 + int(s) + int(ms) / 1000.0
return 0
start_sec = time_to_seconds(start_str)
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
# Remove chapter markers and metadata tags
text = clean_subtitle_text(text)
if text: # Only add non-empty subtitles
subtitles.append((start_sec, end_sec, text))
except (ValueError, IndexError, AttributeError):
continue
return subtitles
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
# 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_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
return timestamp_lines >= 2 and (timestamp_lines / max(len(lines), 1)) > 0.05
except Exception:
return False
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:
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')
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(':')
seconds = int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
milliseconds = int(ms_part.ljust(3, '0')) # Pad to 3 digits
return seconds + milliseconds / 1000.0
else:
parts = time_str.split(':')
return float(int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2]))
entries = []
current_time = None
current_text = []
pre_timestamp_text = [] # Text before first timestamp
for line in lines:
match = re.match(pattern, line.strip())
if match:
# Save previous entry
if current_time is not None and current_text:
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()
if text:
entries.append((0.0, text))
pre_timestamp_text = []
# Start new entry
time_str = match.group(1)
current_time = parse_time(time_str)
current_text = []
elif current_time is not None:
current_text.append(line)
else:
# Text before first timestamp
pre_timestamp_text.append(line)
# Save last entry
if current_time is not None and current_text:
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()
if text:
entries.append((0.0, text))
# Convert to subtitle format with end times
subtitles = []
for i, (start_time, text) in enumerate(entries):
end_time = entries[i + 1][0] if i + 1 < len(entries) else None
# Remove chapter markers and metadata tags
text = clean_subtitle_text(text)
if text: # Only add non-empty entries
subtitles.append((start_time, end_time, text))
return subtitles
def parse_ass_file(file_path):
"""
Parse an ASS/SSA subtitle file and return a list of subtitle entries.
Args:
file_path: Path to the ASS/SSA file
Returns:
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:
lines = f.readlines()
subtitles = []
in_events = False
format_indices = {}
for line in lines:
line = line.strip()
if line.startswith('[Events]'):
in_events = True
continue
if line.startswith('[') and in_events:
# New section, stop processing
break
if in_events and line.startswith('Format:'):
# Parse format line to know column positions
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:'):
continue # Skip comments
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()
# Convert timestamp to seconds (ASS format: H:MM:SS.CS where CS is centiseconds)
def ass_time_to_seconds(t):
parts = t.split(':')
if len(parts) == 3:
h, m, s = parts
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 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
# Remove chapter markers and metadata tags
text = clean_subtitle_text(text)
if text: # Only add non-empty subtitles
subtitles.append((start_sec, end_sec, text))
return subtitles
def get_sample_voice_text(lang_code):
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"])
@@ -95,97 +416,48 @@ def sanitize_name_for_os(name, is_folder=True):
return sanitized
class ChapterOptionsDialog(QDialog):
def __init__(self, chapter_count, parent=None):
class CountdownDialog(QDialog):
"""Base dialog with auto-accept countdown functionality"""
def __init__(self, title, countdown_seconds, parent=None):
super().__init__(parent)
self.setWindowTitle("Chapter Options")
self.setWindowTitle(title)
self.setMinimumWidth(350)
# Prevent closing with the X button and remove the help button
self.setWindowFlags(
self.windowFlags()
& ~Qt.WindowType.WindowCloseButtonHint
& ~Qt.WindowType.WindowContextHelpButtonHint
)
layout = QVBoxLayout(self)
# Add informational label
layout.addWidget(QLabel(f"Detected {chapter_count} chapters in the text file."))
layout.addWidget(QLabel("How would you like to process these chapters?"))
# Add checkboxes
self.save_separately_checkbox = QCheckBox("Save each chapter separately")
self.merge_at_end_checkbox = QCheckBox("Create a merged version at the end")
# Set default states
self.save_separately_checkbox.setChecked(False)
self.merge_at_end_checkbox.setChecked(True)
# Connect checkbox state change signal
self.save_separately_checkbox.stateChanged.connect(
self.update_merge_checkbox_state
)
layout.addWidget(self.save_separately_checkbox)
layout.addWidget(self.merge_at_end_checkbox)
# Countdown label
self.countdown_seconds = CHAPTER_OPTIONS_COUNTDOWN
self.countdown_label = QLabel(
f"Auto-accepting in {self.countdown_seconds} seconds..."
)
self.countdown_seconds = countdown_seconds
self.layout = QVBoxLayout(self)
self._timer = None
self._button_box = None
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.setStyleSheet(f"color: {COLORS['GREEN']};")
layout.addWidget(self.countdown_label)
# Add OK button
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
button_box.accepted.connect(self.accept)
layout.addWidget(button_box)
# Timer for countdown
self.layout.addWidget(self.countdown_label)
self._button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
self._button_box.accepted.connect(self.accept)
self.layout.addWidget(self._button_box)
self._timer = QTimer(self)
self._timer.timeout.connect(self._on_timer_tick)
self._timer.start(1000) # 1 second interval
# Store button_box for later use
self._button_box = button_box
# Initialize merge checkbox state
self.update_merge_checkbox_state()
self._timer.start(1000)
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() # Simulate OK click
def update_merge_checkbox_state(self):
# Enable merge checkbox only if save separately is checked
self.merge_at_end_checkbox.setEnabled(self.save_separately_checkbox.isChecked())
# Don't uncheck it, just leave it in its current state
def get_options(self):
save_separately = self.save_separately_checkbox.isChecked()
# Consider merge_at_end as false if the checkbox is disabled, regardless of its checked state
merge_at_end = (
self.merge_at_end_checkbox.isChecked()
and self.merge_at_end_checkbox.isEnabled()
)
return {
"save_chapters_separately": save_separately,
"merge_chapters_at_end": merge_at_end,
}
# Prevent closing by overriding the closeEvent
self._button_box.accepted.emit()
def closeEvent(self, event):
# Ignore all close events
event.ignore()
# Prevent escape key from closing the dialog
def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_Escape:
event.ignore()
@@ -193,11 +465,119 @@ class ChapterOptionsDialog(QDialog):
super().keyPressEvent(event)
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("How would you like to process these chapters?"))
self.save_separately_checkbox = QCheckBox("Save each chapter separately")
self.merge_at_end_checkbox = QCheckBox("Create a merged version at the end")
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.layout.addWidget(self.save_separately_checkbox)
self.layout.addWidget(self.merge_at_end_checkbox)
self.add_countdown_and_buttons()
self.update_merge_checkbox_state()
def update_merge_checkbox_state(self):
self.merge_at_end_checkbox.setEnabled(self.save_separately_checkbox.isChecked())
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(),
}
class TimestampDetectionDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Timestamps Detected")
self.setMinimumWidth(350)
self.use_timestamps_result = True
self.countdown_seconds = CHAPTER_OPTIONS_COUNTDOWN
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?"))
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)
no_label = QLabel("• No: Ignore timestamps and process as regular text")
no_label.setStyleSheet(f"color: {COLORS['ORANGE']};")
layout.addWidget(no_label)
# Countdown label
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)
button_box = QDialogButtonBox()
yes_button = button_box.addButton("Yes", QDialogButtonBox.ButtonRole.AcceptRole)
no_button = button_box.addButton("No", QDialogButtonBox.ButtonRole.RejectRole)
yes_button.clicked.connect(lambda: self._set_result(True))
no_button.clicked.connect(lambda: self._set_result(False))
layout.addWidget(button_box)
# Timer for countdown
self._timer = QTimer(self)
self._timer.timeout.connect(self._on_timer_tick)
self._timer.start(1000)
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...")
else:
self._timer.stop()
self._set_result(True)
def _set_result(self, use_timestamps):
if self._timer:
self._timer.stop()
self.use_timestamps_result = use_timestamps
self.accept()
def use_timestamps(self):
return self.use_timestamps_result
class ConversionThread(QThread):
progress_updated = pyqtSignal(int, str) # Add str for ETR
conversion_finished = pyqtSignal(object, object) # Pass output path as second arg
log_updated = pyqtSignal(object) # Updated signal for log updates
chapters_detected = pyqtSignal(int) # Signal for chapter detection
# Default split pattern for TTS processing
DEFAULT_SPLIT_PATTERN = r"\n+"
# 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
# Language-specific punctuation patterns for subtitle splitting
LANGUAGE_PUNCTUATION = {
'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
}
}
def __init__(
self,
@@ -246,6 +626,8 @@ class ConversionThread(QThread):
self.use_gpu = use_gpu # Store the GPU setting
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
def _stream_audio_in_chunks(
self, segments, process_func, progress_prefix="Processing"
@@ -354,6 +736,21 @@ class ConversionThread(QThread):
self.log_updated.emit(f"- Save option: {self.save_option}")
if self.replace_single_newlines:
self.log_updated.emit(f"- Replace single newlines: Yes")
# Check if input is a subtitle file for additional configuration
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']:
is_subtitle_input = True
# Display subtitle-specific options if processing subtitle file
if is_subtitle_input:
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"
self.log_updated.emit(f"- Speed adjustment method: {method_label}")
# Display save_chapters_separately flag if it's set
if hasattr(self, "save_chapters_separately"):
@@ -400,6 +797,34 @@ class ConversionThread(QThread):
lang_code=self.lang_code, repo_id="hexgrad/Kokoro-82M", device=device
)
# Check if the input is a subtitle file or timestamp text file
is_subtitle_file = False
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']:
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):
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'):
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')
# Process subtitle files separately
if is_subtitle_file or is_timestamp_text:
self._process_subtitle_file(tts, base_path, is_timestamp_text)
return
if self.is_direct_text:
text = self.file_name # Treat file_name as direct text input
else:
@@ -729,8 +1154,6 @@ class ConversionThread(QThread):
chapter_time = chapters_time[chapter_idx - 1]
if merge_chapters_at_end:
chapter_time["start"] = current_time
# Set split_pattern to \n+ which will split on one or more newlines
split_pattern = r"\n+"
# Check if the voice is a formula and load it if necessary
if "*" in self.voice:
@@ -859,7 +1282,7 @@ class ConversionThread(QThread):
chapter_text,
voice=loaded_voice,
speed=self.speed,
split_pattern=split_pattern,
split_pattern=self.split_pattern,
):
# Print the result for debugging
# print(f"Result: {result}")
@@ -1180,12 +1603,274 @@ class ConversionThread(QThread):
self.log_updated.emit((f"Error occurred: {str(e)}", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
def _process_subtitle_file(self, tts, base_path, is_timestamp_text=False):
"""Process subtitle files with precise timing and generate output subtitles."""
try:
# Parse subtitle file
if is_timestamp_text:
subtitles = parse_timestamp_text_file(self.file_name)
else:
file_ext = os.path.splitext(self.file_name)[1].lower()
if file_ext == '.srt':
subtitles = parse_srt_file(self.file_name)
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)
return
self.log_updated.emit(f"\nFound {len(subtitles)} subtitle entries")
# 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())
if not os.path.exists(parent_dir):
self.log_updated.emit((f"Output folder does not exist: {parent_dir}", "red"))
return
# Find unique filename
counter = 1
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)):
break
counter += 1
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)
else:
static_ffmpeg.add_paths()
cmd = ["ffmpeg", "-y", "-thread_queue_size", "32768", "-f", "f32le", "-ar", str(rate), "-ac", "1", "-i", "pipe:0"]
if self.output_format == "m4b":
cmd.extend(["-c:a", "aac", "-q:a", "2", "-movflags", "+faststart+use_metadata_tags"])
cmd.extend(self._extract_and_add_metadata_tags_to_ffmpeg_cmd())
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"))
return
cmd.append(merged_out_path)
ffmpeg_proc = create_process(cmd, stdin=subprocess.PIPE, text=False)
# Always generate subtitles for subtitle input files
subtitle_file, subtitle_path = None, None
subtitle_format = getattr(self, "subtitle_format", "srt")
file_extension = "ass" if "ass" in subtitle_format else "srt"
subtitle_path = f"{base_filepath_no_ext}.{file_extension}"
subtitle_file = open(subtitle_path, "w", encoding="utf-8", errors="replace")
if "ass" in subtitle_format:
# Write ASS header
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")
is_narrow = subtitle_format in ("ass_narrow", "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
# 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")
# Process each subtitle and mix into buffer
self.etr_start_time = time.time()
srt_index = 1
for idx, (start_time, end_time, text) in enumerate(subtitles, 1):
if self.cancel_requested:
if subtitle_file: subtitle_file.close()
self.conversion_finished.emit("Cancelled", None)
return
# Process text and timing
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")
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)
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"
else:
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}")
# 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]
audio_chunks = [r.audio for r in tts_results]
if self.cancel_requested:
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])
if audio_chunks
else self.np.zeros(int((subtitle_duration or 0) * rate), dtype="float32")
)
audio_duration = len(full_audio) / rate
# Use actual audio length for timing
if use_gaps:
end_time = min(start_time + audio_duration, next_start)
subtitle_duration = end_time - start_time
elif subtitle_duration is None:
subtitle_duration = audio_duration
end_time = start_time + audio_duration
# Speed up if needed
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":
# FFmpeg time-stretch (faster processing)
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))))
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
)
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"))
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])
if audio_chunks
else self.np.zeros(int(subtitle_duration * rate), dtype="float32")
)
audio_duration = len(full_audio) / rate
# Adjust duration after potential speed changes
if use_gaps:
end_time = min(start_time + audio_duration, next_start)
subtitle_duration = end_time - start_time
elif subtitle_duration is None:
subtitle_duration = audio_duration
end_time = start_time + audio_duration
# 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")])
elif len(full_audio) > target_samples:
full_audio = full_audio[:target_samples]
# Mix audio into buffer at the correct position (handles overlaps)
start_sample = int(start_time * rate)
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")])
# Mix (add) the audio - this handles overlaps by combining them
audio_buffer[start_sample:end_sample] += full_audio
# 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")
else:
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}")
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})")
audio_buffer = audio_buffer / max_amplitude
# Write the complete audio buffer
self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey"))
if merged_out_file:
merged_out_file.write(audio_buffer)
merged_out_file.close()
elif ffmpeg_proc:
ffmpeg_proc.stdin.write(audio_buffer.astype("float32").tobytes())
ffmpeg_proc.stdin.close()
ffmpeg_proc.wait()
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 ""))
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()
if "subtitle_file" in locals() and subtitle_file:
subtitle_file.close()
except: pass
self.log_updated.emit((f"Error processing subtitle file: {str(e)}", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None)
def set_chapter_options(self, options):
"""Set chapter options from the dialog and resume processing"""
self.save_chapters_separately = options["save_chapters_separately"]
self.merge_chapters_at_end = options["merge_chapters_at_end"]
self.waiting_for_user_input = False
self._chapter_options_event.set()
def set_timestamp_response(self, treat_as_subtitle):
"""Set whether to treat timestamp text file as subtitle."""
self._timestamp_response = treat_as_subtitle
def _extract_and_add_metadata_tags_to_ffmpeg_cmd(self):
"""Extract metadata tags from text content and add them to ffmpeg command"""
@@ -1313,7 +1998,9 @@ class ConversionThread(QThread):
# Use processed_tokens instead of tokens_with_timestamps for the rest of the method
if self.subtitle_mode == "Sentence + Highlighting":
# Sentence-based processing with karaoke highlighting
separator = r"[.!?]"
# 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"[.!?]"
current_sentence = []
word_count = 0
@@ -1374,9 +2061,13 @@ class ConversionThread(QThread):
if self.subtitle_mode == "Line":
separator = r"\n"
elif self.subtitle_mode == "Sentence":
separator = r"[.!?]"
# 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"[.!?]"
else: # Sentence + Comma
separator = r"[.!?,]"
# 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"[.!?,]"
current_sentence = []
word_count = 0
+130 -16
View File
@@ -161,7 +161,7 @@ class InputBox(QLabel):
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setAcceptDrops(True)
self.setText(
"Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md)"
"Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md, .srt, .ass, .vtt)"
)
self.setStyleSheet(
f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}"
@@ -255,7 +255,7 @@ class InputBox(QLabel):
except Exception:
return str(n)
doc_extensions = (".epub", ".pdf", ".md", ".markdown")
doc_extensions = (".epub", ".pdf", ".md", ".markdown", ".srt", ".ass", ".vtt")
char_source_path = file_path
cached_char_count = None
@@ -321,11 +321,11 @@ class InputBox(QLabel):
else: # PDF - always use Pages
self.chapters_btn.setText(f"Pages ({chapter_count})")
# Hide textbox and show edit only for .txt files
# Hide textbox and show edit only for .txt, .srt, .ass, .vtt files
self.textbox_btn.hide()
# Show edit button for txt files directly
# Show edit button for txt/subtitle files directly
# Or for epub/pdf files that have generated a temp txt file
should_show_edit = file_path.lower().endswith(".txt")
should_show_edit = file_path.lower().endswith((".txt", ".srt", ".ass", ".vtt"))
# For epub/pdf files, show edit if we have a selected_file (temp txt)
if (
@@ -337,6 +337,12 @@ class InputBox(QLabel):
self.edit_btn.setVisible(should_show_edit)
self.go_to_folder_btn.show()
# Disable subtitle generation for subtitle input files
is_subtitle_input = file_path.lower().endswith((".srt", ".ass", ".vtt"))
if hasattr(window, "subtitle_combo"):
window.subtitle_combo.setEnabled(not is_subtitle_input)
# Enable add to queue button only when file is accepted (input box is green)
self.resizeEvent(None)
if hasattr(window, "btn_add_to_queue"):
@@ -367,7 +373,7 @@ class InputBox(QLabel):
self.window().save_chapters_separately = None
self.window().merge_chapters_at_end = None
self.setText(
"Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md)"
"Drag and drop your file here or click to browse.\n(.txt, .epub, .pdf, .md, .srt, .ass, .vtt)"
)
self.setStyleSheet(
f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}"
@@ -379,9 +385,19 @@ class InputBox(QLabel):
self.textbox_btn.show()
self.edit_btn.hide()
self.go_to_folder_btn.hide()
# Re-enable subtitle and replace newlines controls when cleared
window = self.window()
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)
if hasattr(window, "replace_newlines_combo"):
window.replace_newlines_combo.setEnabled(True)
# Disable add to queue button when input is cleared
if hasattr(self.window(), "btn_add_to_queue"):
self.window().btn_add_to_queue.setEnabled(False)
if hasattr(window, "btn_add_to_queue"):
window.btn_add_to_queue.setEnabled(False)
# Reset the input_box_cleared_by_queue flag after setting file info
if hasattr(self.window(), "input_box_cleared_by_queue"):
self.window().input_box_cleared_by_queue = True
@@ -407,6 +423,7 @@ class InputBox(QLabel):
or ext.endswith(".epub")
or ext.endswith(".pdf")
or ext.endswith((".md", ".markdown"))
or ext.endswith((".srt", ".ass", ".vtt"))
):
event.acceptProposedAction()
# Set hover style based on current state
@@ -460,12 +477,20 @@ class InputBox(QLabel):
file_path.lower().endswith(".epub")
or file_path.lower().endswith(".pdf")
or file_path.lower().endswith((".md", ".markdown"))
or file_path.lower().endswith((".srt", ".ass", ".vtt"))
):
# Determine file type
if file_path.lower().endswith(".epub"):
file_type = "epub"
elif file_path.lower().endswith(".pdf"):
file_type = "pdf"
elif file_path.lower().endswith((".srt", ".ass", ".vtt")):
# For subtitle files, treat them like txt files (direct processing)
win.selected_file, win.selected_file_type = file_path, "txt"
win.displayed_file_path = file_path
self.set_file_info(file_path)
event.acceptProposedAction()
return
else:
file_type = "markdown"
@@ -477,7 +502,7 @@ class InputBox(QLabel):
)
event.acceptProposedAction()
else:
self.set_error("Please drop a .txt, .epub, .pdf, or .md file.")
self.set_error("Please drop a .txt, .epub, .pdf, .md, .srt, .ass, or .vtt file.")
event.ignore()
else:
event.ignore()
@@ -796,6 +821,8 @@ class abogen(QWidget):
"use_gpu", True # Load GPU setting with default True
)
self.replace_single_newlines = self.config.get("replace_single_newlines", False)
self.use_silent_gaps = self.config.get("use_silent_gaps", False)
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
self._pending_close_event = None
self.gpu_ok = False # Initialize GPU availability status
@@ -1291,7 +1318,7 @@ class abogen(QWidget):
return
try:
file_path, _ = QFileDialog.getOpenFileName(
self, "Select File", "", "Supported Files (*.txt *.epub *.pdf *.md)"
self, "Select File", "", "Supported Files (*.txt *.epub *.pdf *.md *.srt *.ass *.vtt)"
)
if not file_path:
return
@@ -1312,6 +1339,11 @@ class abogen(QWidget):
# Don't set file info immediately, open_book_file will handle it after dialog is accepted
if not self.open_book_file(file_path):
return
elif file_path.lower().endswith((".srt", ".ass", ".vtt")):
# Handle subtitle files like text files
self.selected_file, self.selected_file_type = file_path, "txt"
self.displayed_file_path = file_path
self.input_box.set_file_info(file_path)
else:
self.selected_file, self.selected_file_type = file_path, "txt"
self.displayed_file_path = (
@@ -1817,6 +1849,8 @@ class abogen(QWidget):
output_format=self.selected_format,
total_char_count=self.char_count,
replace_single_newlines=self.replace_single_newlines,
use_silent_gaps=self.use_silent_gaps,
subtitle_speed_method=self.subtitle_speed_method,
save_base_path=save_base_path,
save_chapters_separately=getattr(self, "save_chapters_separately", None),
merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None),
@@ -1900,6 +1934,9 @@ 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
)
# Restore the original file path for save location
self.displayed_file_path = (
queued_item.save_base_path or queued_item.file_name
@@ -2045,6 +2082,12 @@ class abogen(QWidget):
self.conversion_thread.replace_single_newlines = (
self.replace_single_newlines
)
# Pass use_silent_gaps setting
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
self.conversion_thread.separate_chapters_format = (
self.separate_chapters_format
@@ -2767,21 +2810,31 @@ class abogen(QWidget):
def show_chapter_options_dialog(self, chapter_count):
"""Show dialog to ask user about chapter processing options when chapters are detected in a .txt file"""
# Check if this is a timestamp detection (-1) or chapter detection
if chapter_count == -1:
from abogen.conversion import TimestampDetectionDialog
dialog = TimestampDetectionDialog(parent=self)
dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
# 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():
self.conversion_thread.set_timestamp_response(treat_as_subtitle)
return
# Normal chapter detection
from abogen.conversion import ChapterOptionsDialog
dialog = ChapterOptionsDialog(chapter_count, parent=self)
dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
# If dialog is accepted, pass the options to the conversion thread
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:
# If dialog is rejected, cancel the conversion
self.cancel_conversion()
def apply_theme(self, theme):
@@ -3040,6 +3093,40 @@ class abogen(QWidget):
# Add separator
menu.addSeparator()
# Add use silent gaps option (for subtitle files)
self.silent_gaps_action = QAction("Use silent gaps between subtitles", self)
self.silent_gaps_action.setCheckable(True)
self.silent_gaps_action.setChecked(self.use_silent_gaps)
self.silent_gaps_action.triggered.connect(
lambda checked: self.toggle_use_silent_gaps(checked)
)
menu.addAction(self.silent_gaps_action)
# Subtitle speed adjustment method
speed_method_menu = menu.addMenu("Subtitle speed adjustment method")
speed_method_menu.setToolTip(
"Choose speed adjustment method:\n"
"TTS Regeneration: Better quality\n"
"FFmpeg Time-stretch: Faster processing"
)
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)")]:
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))
speed_method_group.addAction(action)
speed_method_menu.addAction(action)
self.speed_method_group = speed_method_group
# Add separator
menu.addSeparator()
# Add "Disable Kokoro's internet access" option
disable_kokoro_action = QAction("Disable Kokoro's internet access", self)
disable_kokoro_action.setCheckable(True)
@@ -3075,6 +3162,33 @@ class abogen(QWidget):
self.config["replace_single_newlines"] = enabled
save_config(self.config)
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?")
reply = QMessageBox.question(
self,
"Use Silent Gaps Between Subtitles",
message,
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
)
if reply == QMessageBox.StandardButton.Ok:
self.use_silent_gaps = enabled
self.config["use_silent_gaps"] = enabled
save_config(self.config)
else:
# Revert the checkbox state if cancelled
self.silent_gaps_action.setChecked(not enabled)
def toggle_subtitle_speed_method(self, method):
self.subtitle_speed_method = method
self.config["subtitle_speed_method"] = method
save_config(self.config)
def restart_app(self):
import sys
+1 -1
View File
@@ -146,7 +146,7 @@ def main():
# Set the .desktop name on Linux
if platform.system() == "Linux":
try:
app.setDesktopFileName("abogen.desktop")
app.setDesktopFileName("abogen")
except AttributeError:
pass
+27 -8
View File
@@ -87,7 +87,8 @@ class DroppableQueueListWidget(QListWidget):
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
for url in event.mimeData().urls():
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt"):
file_path = url.toLocalFile().lower()
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()
@@ -98,7 +99,8 @@ class DroppableQueueListWidget(QListWidget):
def dragMoveEvent(self, event):
if event.mimeData().hasUrls():
for url in event.mimeData().urls():
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt"):
file_path = url.toLocalFile().lower()
if url.isLocalFile() and (file_path.endswith(".txt") or file_path.endswith((".srt", ".ass", ".vtt"))):
event.acceptProposedAction()
return
event.ignore()
@@ -113,7 +115,7 @@ class DroppableQueueListWidget(QListWidget):
file_paths = [
url.toLocalFile()
for url in event.mimeData().urls()
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt")
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)
@@ -151,7 +153,7 @@ class QueueManager(QDialog):
# Add informative instructions at the top
instructions = QLabel(
"<h2>How Queue Works?</h2>"
"You can add text files (.txt) directly using the '<b>Add files</b>' button below. "
"You can add text and subtitle files (.txt, .srt, .ass, .vtt) directly using the '<b>Add files</b>' button below. "
"To add PDF, EPUB or markdown files, use the input box in the main window and click the <b>'Add to Queue'</b> button. "
"Each file in the queue keeps the configuration settings active when it was added. "
"Changing the main window configuration afterward <b>does not</b> affect files already in the queue. "
@@ -163,7 +165,7 @@ class QueueManager(QDialog):
layout.addWidget(instructions)
# Overlay label for empty queue
self.empty_overlay = QLabel(
"Drag and drop your text files here or use the 'Add files' button.",
"Drag and drop your text or subtitle files here or use the 'Add files' button.",
self.listwidget,
)
self.empty_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -290,7 +292,9 @@ class QueueManager(QDialog):
f"<b>Subtitle Mode:</b> {getattr(item, 'subtitle_mode', '')}<br>"
f"<b>Output Format:</b> {getattr(item, 'output_format', '')}<br>"
f"<b>Characters:</b> {getattr(item, 'total_char_count', '')}<br>"
f"<b>Replace Single Newlines:</b> {getattr(item, 'replace_single_newlines', False)}"
f"<b>Replace Single Newlines:</b> {getattr(item, 'replace_single_newlines', False)}<br>"
f"<b>Use Silent Gaps:</b> {getattr(item, 'use_silent_gaps', False)}<br>"
f"<b>Speed Method:</b> {getattr(item, 'subtitle_speed_method', 'tts')}"
)
# Add book handler options if present
save_chapters_separately = getattr(item, "save_chapters_separately", None)
@@ -403,6 +407,14 @@ class QueueManager(QDialog):
attrs["replace_single_newlines"] = getattr(
parent, "replace_single_newlines", False
)
# use_silent_gaps
attrs["use_silent_gaps"] = getattr(
parent, "use_silent_gaps", False
)
# subtitle_speed_method
attrs["subtitle_speed_method"] = getattr(
parent, "subtitle_speed_method", "tts"
)
# book handler options
attrs["save_chapters_separately"] = getattr(
parent, "save_chapters_separately", None
@@ -449,6 +461,9 @@ class QueueManager(QDialog):
)
for attr, value in current_attrs.items():
setattr(item, attr, value)
# Override subtitle_mode to "Disabled" for subtitle files
if file_path.lower().endswith((".srt", ".ass", ".vtt")):
item.subtitle_mode = "Disabled"
# Read file content and calculate total_char_count using calculate_text_length
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
@@ -480,6 +495,10 @@ class QueueManager(QDialog):
== getattr(item, "total_char_count", None)
and getattr(queued_item, "replace_single_newlines", False)
== getattr(item, "replace_single_newlines", False)
and getattr(queued_item, "use_silent_gaps", False)
== getattr(item, "use_silent_gaps", False)
and getattr(queued_item, "subtitle_speed_method", "tts")
== getattr(item, "subtitle_speed_method", "tts")
and getattr(queued_item, "save_base_path", None)
== getattr(item, "save_base_path", None)
and getattr(queued_item, "save_chapters_separately", None)
@@ -506,9 +525,9 @@ class QueueManager(QDialog):
from PyQt6.QtWidgets import QFileDialog
from abogen.utils import calculate_text_length # import the function
# Only allow .txt files
# Allow .txt, .srt, .ass, and .vtt files
files, _ = QFileDialog.getOpenFileNames(
self, "Select .txt files", "", "Text Files (*.txt)"
self, "Select text or subtitle files", "", "Supported Files (*.txt *.srt *.ass *.vtt)"
)
if not files:
return
+2
View File
@@ -14,6 +14,8 @@ class QueuedItem:
output_format: str
total_char_count: int
replace_single_newlines: bool = False
use_silent_gaps: bool = False
subtitle_speed_method: str = "tts"
save_base_path: str = None
save_chapters_separately: bool = None
merge_chapters_at_end: bool = None