mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Add subtitle file voicing feature, improvements in code and documentation
This commit is contained in:
+5
-1
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user