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
+6
View File
@@ -1,6 +1,12 @@
# 1.2.2 (pre-release) # 1.2.2 (pre-release)
- **You can now voice your subtitle files!** Simply add `.srt`, `.ass` or `.vtt` files to generate timed audio. Alternatively, add a text file with timestamps in `HH:MM:SS` or `HH:MM:SS,ms` format to generate audio that matches the timestamps.
- New option: **"Use silent gaps between subtitles"**: Prevents unnecessary audio speed-up by letting speech continue naturally into the silent gaps between subtitles.
- New option: **"Subtitle speed adjustment method"**: Choose how to speed up audio when needed:
- **TTS Regeneration (better quality):** Re-creates the audio at a faster speed for the most natural sound.
- **FFmpeg Time-stretch (better speed):** Quickly speeds up the existing audio.
- Fixed `[WinError 1114] A dynamic link library (DLL) initialization routine failed` error on Windows, pre-loading PyTorch DLLs before initializing PyQt6 to avoid DLL initialization errors, mentioned in #98 by @ephr0n. - Fixed `[WinError 1114] A dynamic link library (DLL) initialization routine failed` error on Windows, pre-loading PyTorch DLLs before initializing PyQt6 to avoid DLL initialization errors, mentioned in #98 by @ephr0n.
- Potential fix for `CUDA GPU is not available` issue, by ensuring PyTorch is installed correctly with CUDA support on Windows using the installer script. - Potential fix for `CUDA GPU is not available` issue, by ensuring PyTorch is installed correctly with CUDA support on Windows using the installer script.
- Improvements in code and documentation.
# 1.2.1 # 1.2.1
- Upgraded Abogen's interface from PyQt5 to PyQt6 for better compatibility and long-term support. - Upgraded Abogen's interface from PyQt5 to PyQt6 for better compatibility and long-term support.
+27 -4
View File
@@ -9,7 +9,7 @@
<a href="https://trendshift.io/repositories/14433" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14433" alt="denizsafak%2Fabogen | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a> <a href="https://trendshift.io/repositories/14433" target="_blank"><img src="https://trendshift.io/api/badge/repositories/14433" alt="denizsafak%2Fabogen | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
Abogen is a powerful text-to-speech conversion tool that makes it easy to turn ePub, PDF, text or markdown files into high-quality audio with matching subtitles in seconds. Use it for audiobooks, voiceovers for Instagram, YouTube, TikTok, or any project that needs natural-sounding text-to-speech, using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M). Abogen is a powerful text-to-speech conversion tool that makes it easy to turn ePub, PDF, text, markdown, or subtitle files (`.srt`, `.ass`, `.vtt`) into high-quality audio with matching subtitles in seconds. Use it for audiobooks, voiceovers for Instagram, YouTube, TikTok, or any project that needs natural-sounding text-to-speech, using [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M).
<img title="Abogen Main" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen.png' width="380"> <img title="Abogen Processing" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen2.png' width="380"> <img title="Abogen Main" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen.png' width="380"> <img title="Abogen Processing" src='https://raw.githubusercontent.com/denizsafak/abogen/refs/heads/main/demo/abogen2.png' width="380">
@@ -115,7 +115,7 @@ abogen
> If you installed Abogen using the Windows installer `(WINDOWS_INSTALL.bat)`, It should have created a shortcut in the same folder, or your desktop. You can run it from there. If you lost the shortcut, Abogen is located in `python_embedded/Scripts/abogen.exe`. You can run it from there directly. > If you installed Abogen using the Windows installer `(WINDOWS_INSTALL.bat)`, It should have created a shortcut in the same folder, or your desktop. You can run it from there. If you lost the shortcut, Abogen is located in `python_embedded/Scripts/abogen.exe`. You can run it from there directly.
## `How to use?` ## `How to use?`
1) Drag and drop any ePub, PDF, text or markdown file (or use the built-in text editor) 1) Drag and drop any ePub, PDF, text, markdown, or subtitle file (`.srt`, `.ass`, `.vtt`) (or use the built-in text editor)
2) Configure the settings: 2) Configure the settings:
- Set speech speed - Set speech speed
- Select a voice (or create a custom voice using voice mixer) - Select a voice (or create a custom voice using voice mixer)
@@ -133,7 +133,7 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
| Options | Description | | Options | Description |
|---------|-------------| |---------|-------------|
| **Input Box** | Drag and drop `ePub`, `PDF`, `.TXT` or `.MD` files (or use built-in text editor) | | **Input Box** | Drag and drop `ePub`, `PDF`, `.TXT`, `.MD`, `.SRT`, `.ASS` or `.VTT` files (or use built-in text editor) |
| **Queue options** | Add multiple files to a queue and process them in batch, with individual settings for each file. See [Queue mode](#queue-mode) for more details. | | **Queue options** | Add multiple files to a queue and process them in batch, with individual settings for each file. See [Queue mode](#queue-mode) for more details. |
| **Speed** | Adjust speech rate from `0.1x` to `2.0x` | | **Speed** | Adjust speech rate from `0.1x` to `2.0x` |
| **Select Voice** | First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. | | **Select Voice** | First letter of the language code (e.g., `a` for American English, `b` for British English, etc.), second letter is for `m` for male and `f` for female. |
@@ -169,6 +169,8 @@ Heres Abogen in action: in this demo, it processes 3,000 characters of tex
| **Open config directory** | Opens the directory where the configuration file is stored. | | **Open config directory** | Opens the directory where the configuration file is stored. |
| **Open cache directory** | Opens the cache directory where converted text files are stored. | | **Open cache directory** | Opens the cache directory where converted text files are stored. |
| **Clear cache files** | Deletes cache files created during the conversion or preview. | | **Clear cache files** | Deletes cache files created during the conversion or preview. |
| **Use silent gaps between subtitles** | Prevents unnecessary audio speed-up by letting speech continue naturally into the silent gaps between subtitles. (for subtitle files). |
| **Subtitle speed adjustment method** | Choose how to speed up audio when needed: `TTS Regeneration (better quality)` re-creates the audio at a faster speed, while `FFmpeg Time-stretch (better speed)` quickly speeds up the existing audio (for subtitle files). |
| **Check for updates at startup** | Automatically checks for updates when the program starts. | | **Check for updates at startup** | Automatically checks for updates when the program starts. |
| **Disable Kokoro's internet access** | Prevents Kokoro from downloading models or voices from HuggingFace Hub, useful for offline use. | | **Disable Kokoro's internet access** | Prevents Kokoro from downloading models or voices from HuggingFace Hub, useful for offline use. |
| **Reset to default settings** | Resets all settings to their default values. | | **Reset to default settings** | Resets all settings to their default values. |
@@ -230,6 +232,27 @@ Similar to chapter markers, it is possible to add metadata tags for `M4B` files.
<<METADATA_GENRE:Audiobook>> <<METADATA_GENRE:Audiobook>>
``` ```
## `About Timestamp-based Text Files`
Abogen can automatically detect text files that contain timestamps in `HH:MM:SS` or `HH:MM:SS,ms` format. When timestamps are found inside your text file, Abogen will ask if you want to use them for audio timing. This is useful for creating timed narrations, scripts, or transcripts where you need exact control over when each segment is spoken.
Format your text file like this:
```
00:00:00
This is the first segment of text.
00:00:15
This is the second segment, starting at 15 seconds.
00:00:45
And this is the third segment, starting at 45 seconds.
```
**Important notes:**
- Timestamps must be in `HH:MM:SS` or `HH:MM:SS,ms` format (e.g., `00:05:30` for 5 minutes 30 seconds, or `00:05:30,500` for 5 minutes 30.5 seconds)
- Milliseconds are optional and provide precision up to 1/1000th of a second
- Text before the first timestamp (if any) will automatically start at `00:00:00`
- When using timestamps, the subtitle generation mode setting is ignored
## `Supported Languages` ## `Supported Languages`
``` ```
# 🇺🇸 'a' => American English, 🇬🇧 'b' => British English # 🇺🇸 'a' => American English, 🇬🇧 'b' => British English
@@ -465,4 +488,4 @@ This project is available under the MIT License - see the [LICENSE](https://gith
> [!IMPORTANT] > [!IMPORTANT]
> Subtitle generation currently works only for English. This is because Kokoro provides timestamp tokens only for English text. If you want subtitles in other languages, please request this feature in the [Kokoro project](https://github.com/hexgrad/kokoro). For more technical details, see [this line](https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py#L383) in the Kokoro's code. > Subtitle generation currently works only for English. This is because Kokoro provides timestamp tokens only for English text. If you want subtitles in other languages, please request this feature in the [Kokoro project](https://github.com/hexgrad/kokoro). For more technical details, see [this line](https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py#L383) in the Kokoro's code.
> Tags: audiobook, kokoro, text-to-speech, TTS, audiobook generator, audiobooks, text to speech, audiobook maker, audiobook creator, audiobook generator, voice-synthesis, text to audio, text to audio converter, text to speech converter, text to speech generator, text to speech software, text to speech app, epub to audio, pdf to audio, markdown to audio, content-creation, media-generation > Tags: audiobook, kokoro, text-to-speech, TTS, audiobook generator, audiobooks, text to speech, audiobook maker, audiobook creator, audiobook generator, voice-synthesis, text to audio, text to audio converter, text to speech converter, text to speech generator, text to speech software, text to speech app, epub to audio, pdf to audio, markdown to audio, subtitle to audio, srt to audio, ass to audio, vtt to audio, webvtt to audio, content-creation, media-generation
+5 -1
View File
@@ -3,7 +3,7 @@ from abogen.utils import get_version
# Program Information # Program Information
PROGRAM_NAME = "abogen" PROGRAM_NAME = "abogen"
PROGRAM_DESCRIPTION = ( PROGRAM_DESCRIPTION = (
"Generate audiobooks from EPUBs, PDFs and text with synchronized captions." "Generate audiobooks from EPUBs, PDFs, text and subtitles with synchronized captions."
) )
GITHUB_URL = "https://github.com/denizsafak/abogen" GITHUB_URL = "https://github.com/denizsafak/abogen"
VERSION = get_version() VERSION = get_version()
@@ -44,6 +44,7 @@ SUPPORTED_SOUND_FORMATS = [
SUPPORTED_SUBTITLE_FORMATS = [ SUPPORTED_SUBTITLE_FORMATS = [
"srt", "srt",
"ass", "ass",
"vtt",
] ]
# Supported input formats # Supported input formats
@@ -51,6 +52,9 @@ SUPPORTED_INPUT_FORMATS = [
"epub", "epub",
"pdf", "pdf",
"txt", "txt",
"srt",
"ass",
"vtt",
] ]
# Supported languages for subtitle generation # Supported languages for subtitle generation
+771 -80
View File
@@ -29,6 +29,327 @@ import subprocess
import platform 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): def get_sample_voice_text(lang_code):
return SAMPLE_VOICE_TEXTS.get(lang_code, SAMPLE_VOICE_TEXTS["a"]) 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 return sanitized
class ChapterOptionsDialog(QDialog): class CountdownDialog(QDialog):
def __init__(self, chapter_count, parent=None): """Base dialog with auto-accept countdown functionality"""
def __init__(self, title, countdown_seconds, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Chapter Options") self.setWindowTitle(title)
self.setMinimumWidth(350) self.setMinimumWidth(350)
# Prevent closing with the X button and remove the help button
self.setWindowFlags( self.setWindowFlags(
self.windowFlags() self.windowFlags()
& ~Qt.WindowType.WindowCloseButtonHint & ~Qt.WindowType.WindowCloseButtonHint
& ~Qt.WindowType.WindowContextHelpButtonHint & ~Qt.WindowType.WindowContextHelpButtonHint
) )
layout = QVBoxLayout(self) self.countdown_seconds = countdown_seconds
self.layout = QVBoxLayout(self)
# Add informational label self._timer = None
layout.addWidget(QLabel(f"Detected {chapter_count} chapters in the text file.")) self._button_box = None
layout.addWidget(QLabel("How would you like to process these chapters?"))
def add_countdown_and_buttons(self):
# Add checkboxes """Add countdown label and OK button - call this after adding custom content"""
self.save_separately_checkbox = QCheckBox("Save each chapter separately") self.countdown_label = QLabel(f"Auto-accepting in {self.countdown_seconds} seconds...")
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_label.setStyleSheet(f"color: {COLORS['GREEN']};") self.countdown_label.setStyleSheet(f"color: {COLORS['GREEN']};")
layout.addWidget(self.countdown_label) self.layout.addWidget(self.countdown_label)
# Add OK button self._button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok)
button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok) self._button_box.accepted.connect(self.accept)
button_box.accepted.connect(self.accept) self.layout.addWidget(self._button_box)
layout.addWidget(button_box)
# Timer for countdown
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.timeout.connect(self._on_timer_tick) self._timer.timeout.connect(self._on_timer_tick)
self._timer.start(1000) # 1 second interval self._timer.start(1000)
# Store button_box for later use
self._button_box = button_box
# Initialize merge checkbox state
self.update_merge_checkbox_state()
def _on_timer_tick(self): def _on_timer_tick(self):
self.countdown_seconds -= 1 self.countdown_seconds -= 1
if self.countdown_seconds > 0: if self.countdown_seconds > 0:
self.countdown_label.setText( self.countdown_label.setText(f"Auto-accepting in {self.countdown_seconds} seconds...")
f"Auto-accepting in {self.countdown_seconds} seconds..."
)
else: else:
self._timer.stop() self._timer.stop()
self._button_box.accepted.emit() # Simulate OK click self._button_box.accepted.emit()
def update_merge_checkbox_state(self):
# Enable merge checkbox only if save separately is checked
self.merge_at_end_checkbox.setEnabled(self.save_separately_checkbox.isChecked())
# Don't uncheck it, just leave it in its current state
def get_options(self):
save_separately = self.save_separately_checkbox.isChecked()
# Consider merge_at_end as false if the checkbox is disabled, regardless of its checked state
merge_at_end = (
self.merge_at_end_checkbox.isChecked()
and self.merge_at_end_checkbox.isEnabled()
)
return {
"save_chapters_separately": save_separately,
"merge_chapters_at_end": merge_at_end,
}
# Prevent closing by overriding the closeEvent
def closeEvent(self, event): def closeEvent(self, event):
# Ignore all close events
event.ignore() event.ignore()
# Prevent escape key from closing the dialog
def keyPressEvent(self, event): def keyPressEvent(self, event):
if event.key() == Qt.Key.Key_Escape: if event.key() == Qt.Key.Key_Escape:
event.ignore() event.ignore()
@@ -193,11 +465,119 @@ class ChapterOptionsDialog(QDialog):
super().keyPressEvent(event) 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): class ConversionThread(QThread):
progress_updated = pyqtSignal(int, str) # Add str for ETR progress_updated = pyqtSignal(int, str) # Add str for ETR
conversion_finished = pyqtSignal(object, object) # Pass output path as second arg conversion_finished = pyqtSignal(object, object) # Pass output path as second arg
log_updated = pyqtSignal(object) # Updated signal for log updates log_updated = pyqtSignal(object) # Updated signal for log updates
chapters_detected = pyqtSignal(int) # Signal for chapter detection 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__( def __init__(
self, self,
@@ -246,6 +626,8 @@ class ConversionThread(QThread):
self.use_gpu = use_gpu # Store the GPU setting self.use_gpu = use_gpu # Store the GPU setting
self.max_subtitle_words = 50 # Default value, will be overridden from GUI self.max_subtitle_words = 50 # Default value, will be overridden from GUI
self.silence_duration = 2.0 # Default value, will be overridden from GUI self.silence_duration = 2.0 # Default value, will be overridden from GUI
# Set split pattern based on language - some languages handle splitting better internally
self.split_pattern = None if lang_code in self.NO_SPLIT_LANGUAGES else self.DEFAULT_SPLIT_PATTERN
def _stream_audio_in_chunks( def _stream_audio_in_chunks(
self, segments, process_func, progress_prefix="Processing" self, segments, process_func, progress_prefix="Processing"
@@ -354,6 +736,21 @@ class ConversionThread(QThread):
self.log_updated.emit(f"- Save option: {self.save_option}") self.log_updated.emit(f"- Save option: {self.save_option}")
if self.replace_single_newlines: if self.replace_single_newlines:
self.log_updated.emit(f"- Replace single newlines: Yes") 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 # Display save_chapters_separately flag if it's set
if hasattr(self, "save_chapters_separately"): 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 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: if self.is_direct_text:
text = self.file_name # Treat file_name as direct text input text = self.file_name # Treat file_name as direct text input
else: else:
@@ -729,8 +1154,6 @@ class ConversionThread(QThread):
chapter_time = chapters_time[chapter_idx - 1] chapter_time = chapters_time[chapter_idx - 1]
if merge_chapters_at_end: if merge_chapters_at_end:
chapter_time["start"] = current_time 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 # Check if the voice is a formula and load it if necessary
if "*" in self.voice: if "*" in self.voice:
@@ -859,7 +1282,7 @@ class ConversionThread(QThread):
chapter_text, chapter_text,
voice=loaded_voice, voice=loaded_voice,
speed=self.speed, speed=self.speed,
split_pattern=split_pattern, split_pattern=self.split_pattern,
): ):
# Print the result for debugging # Print the result for debugging
# print(f"Result: {result}") # print(f"Result: {result}")
@@ -1180,12 +1603,274 @@ class ConversionThread(QThread):
self.log_updated.emit((f"Error occurred: {str(e)}", "red")) self.log_updated.emit((f"Error occurred: {str(e)}", "red"))
self.conversion_finished.emit(("Audio generation failed.", "red"), None) 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): def set_chapter_options(self, options):
"""Set chapter options from the dialog and resume processing""" """Set chapter options from the dialog and resume processing"""
self.save_chapters_separately = options["save_chapters_separately"] self.save_chapters_separately = options["save_chapters_separately"]
self.merge_chapters_at_end = options["merge_chapters_at_end"] self.merge_chapters_at_end = options["merge_chapters_at_end"]
self.waiting_for_user_input = False self.waiting_for_user_input = False
self._chapter_options_event.set() 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): def _extract_and_add_metadata_tags_to_ffmpeg_cmd(self):
"""Extract metadata tags from text content and add them to ffmpeg command""" """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 # Use processed_tokens instead of tokens_with_timestamps for the rest of the method
if self.subtitle_mode == "Sentence + Highlighting": if self.subtitle_mode == "Sentence + Highlighting":
# Sentence-based processing with karaoke 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 = [] current_sentence = []
word_count = 0 word_count = 0
@@ -1374,9 +2061,13 @@ class ConversionThread(QThread):
if self.subtitle_mode == "Line": if self.subtitle_mode == "Line":
separator = r"\n" separator = r"\n"
elif self.subtitle_mode == "Sentence": 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 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 = [] current_sentence = []
word_count = 0 word_count = 0
+130 -16
View File
@@ -161,7 +161,7 @@ class InputBox(QLabel):
self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setAcceptDrops(True) self.setAcceptDrops(True)
self.setText( 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( self.setStyleSheet(
f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}"
@@ -255,7 +255,7 @@ class InputBox(QLabel):
except Exception: except Exception:
return str(n) return str(n)
doc_extensions = (".epub", ".pdf", ".md", ".markdown") doc_extensions = (".epub", ".pdf", ".md", ".markdown", ".srt", ".ass", ".vtt")
char_source_path = file_path char_source_path = file_path
cached_char_count = None cached_char_count = None
@@ -321,11 +321,11 @@ class InputBox(QLabel):
else: # PDF - always use Pages else: # PDF - always use Pages
self.chapters_btn.setText(f"Pages ({chapter_count})") 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() 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 # 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) # For epub/pdf files, show edit if we have a selected_file (temp txt)
if ( if (
@@ -337,6 +337,12 @@ class InputBox(QLabel):
self.edit_btn.setVisible(should_show_edit) self.edit_btn.setVisible(should_show_edit)
self.go_to_folder_btn.show() 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) # Enable add to queue button only when file is accepted (input box is green)
self.resizeEvent(None) self.resizeEvent(None)
if hasattr(window, "btn_add_to_queue"): if hasattr(window, "btn_add_to_queue"):
@@ -367,7 +373,7 @@ class InputBox(QLabel):
self.window().save_chapters_separately = None self.window().save_chapters_separately = None
self.window().merge_chapters_at_end = None self.window().merge_chapters_at_end = None
self.setText( 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( self.setStyleSheet(
f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}" f"QLabel {{ {self.STYLE_DEFAULT} }} QLabel:hover {{ {self.STYLE_DEFAULT_HOVER} }}"
@@ -379,9 +385,19 @@ class InputBox(QLabel):
self.textbox_btn.show() self.textbox_btn.show()
self.edit_btn.hide() self.edit_btn.hide()
self.go_to_folder_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 # Disable add to queue button when input is cleared
if hasattr(self.window(), "btn_add_to_queue"): if hasattr(window, "btn_add_to_queue"):
self.window().btn_add_to_queue.setEnabled(False) window.btn_add_to_queue.setEnabled(False)
# Reset the input_box_cleared_by_queue flag after setting file info # Reset the input_box_cleared_by_queue flag after setting file info
if hasattr(self.window(), "input_box_cleared_by_queue"): if hasattr(self.window(), "input_box_cleared_by_queue"):
self.window().input_box_cleared_by_queue = True self.window().input_box_cleared_by_queue = True
@@ -407,6 +423,7 @@ class InputBox(QLabel):
or ext.endswith(".epub") or ext.endswith(".epub")
or ext.endswith(".pdf") or ext.endswith(".pdf")
or ext.endswith((".md", ".markdown")) or ext.endswith((".md", ".markdown"))
or ext.endswith((".srt", ".ass", ".vtt"))
): ):
event.acceptProposedAction() event.acceptProposedAction()
# Set hover style based on current state # Set hover style based on current state
@@ -460,12 +477,20 @@ class InputBox(QLabel):
file_path.lower().endswith(".epub") file_path.lower().endswith(".epub")
or file_path.lower().endswith(".pdf") or file_path.lower().endswith(".pdf")
or file_path.lower().endswith((".md", ".markdown")) or file_path.lower().endswith((".md", ".markdown"))
or file_path.lower().endswith((".srt", ".ass", ".vtt"))
): ):
# Determine file type # Determine file type
if file_path.lower().endswith(".epub"): if file_path.lower().endswith(".epub"):
file_type = "epub" file_type = "epub"
elif file_path.lower().endswith(".pdf"): elif file_path.lower().endswith(".pdf"):
file_type = "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: else:
file_type = "markdown" file_type = "markdown"
@@ -477,7 +502,7 @@ class InputBox(QLabel):
) )
event.acceptProposedAction() event.acceptProposedAction()
else: 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() event.ignore()
else: else:
event.ignore() event.ignore()
@@ -796,6 +821,8 @@ class abogen(QWidget):
"use_gpu", True # Load GPU setting with default True "use_gpu", True # Load GPU setting with default True
) )
self.replace_single_newlines = self.config.get("replace_single_newlines", False) 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._pending_close_event = None
self.gpu_ok = False # Initialize GPU availability status self.gpu_ok = False # Initialize GPU availability status
@@ -1291,7 +1318,7 @@ class abogen(QWidget):
return return
try: try:
file_path, _ = QFileDialog.getOpenFileName( 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: if not file_path:
return return
@@ -1312,6 +1339,11 @@ class abogen(QWidget):
# Don't set file info immediately, open_book_file will handle it after dialog is accepted # Don't set file info immediately, open_book_file will handle it after dialog is accepted
if not self.open_book_file(file_path): if not self.open_book_file(file_path):
return 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: else:
self.selected_file, self.selected_file_type = file_path, "txt" self.selected_file, self.selected_file_type = file_path, "txt"
self.displayed_file_path = ( self.displayed_file_path = (
@@ -1817,6 +1849,8 @@ class abogen(QWidget):
output_format=self.selected_format, output_format=self.selected_format,
total_char_count=self.char_count, total_char_count=self.char_count,
replace_single_newlines=self.replace_single_newlines, 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_base_path=save_base_path,
save_chapters_separately=getattr(self, "save_chapters_separately", None), save_chapters_separately=getattr(self, "save_chapters_separately", None),
merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None), merge_chapters_at_end=getattr(self, "merge_chapters_at_end", None),
@@ -1900,6 +1934,9 @@ class abogen(QWidget):
self.replace_single_newlines = getattr( self.replace_single_newlines = getattr(
queued_item, "replace_single_newlines", False queued_item, "replace_single_newlines", False
) )
self.use_silent_gaps = getattr(
queued_item, "use_silent_gaps", False
)
# Restore the original file path for save location # Restore the original file path for save location
self.displayed_file_path = ( self.displayed_file_path = (
queued_item.save_base_path or queued_item.file_name queued_item.save_base_path or queued_item.file_name
@@ -2045,6 +2082,12 @@ class abogen(QWidget):
self.conversion_thread.replace_single_newlines = ( self.conversion_thread.replace_single_newlines = (
self.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 # Pass separate_chapters_format setting
self.conversion_thread.separate_chapters_format = ( self.conversion_thread.separate_chapters_format = (
self.separate_chapters_format self.separate_chapters_format
@@ -2767,21 +2810,31 @@ class abogen(QWidget):
def show_chapter_options_dialog(self, chapter_count): 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""" """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 from abogen.conversion import ChapterOptionsDialog
dialog = ChapterOptionsDialog(chapter_count, parent=self) dialog = ChapterOptionsDialog(chapter_count, parent=self)
dialog.setWindowModality(Qt.WindowModality.ApplicationModal) dialog.setWindowModality(Qt.WindowModality.ApplicationModal)
# If dialog is accepted, pass the options to the conversion thread
if dialog.exec() == QDialog.DialogCode.Accepted: if dialog.exec() == QDialog.DialogCode.Accepted:
options = dialog.get_options() options = dialog.get_options()
if ( if hasattr(self, "conversion_thread") and self.conversion_thread.isRunning():
hasattr(self, "conversion_thread")
and self.conversion_thread.isRunning()
):
self.conversion_thread.set_chapter_options(options) self.conversion_thread.set_chapter_options(options)
else: else:
# If dialog is rejected, cancel the conversion
self.cancel_conversion() self.cancel_conversion()
def apply_theme(self, theme): def apply_theme(self, theme):
@@ -3040,6 +3093,40 @@ class abogen(QWidget):
# Add separator # Add separator
menu.addSeparator() 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 # Add "Disable Kokoro's internet access" option
disable_kokoro_action = QAction("Disable Kokoro's internet access", self) disable_kokoro_action = QAction("Disable Kokoro's internet access", self)
disable_kokoro_action.setCheckable(True) disable_kokoro_action.setCheckable(True)
@@ -3075,6 +3162,33 @@ class abogen(QWidget):
self.config["replace_single_newlines"] = enabled self.config["replace_single_newlines"] = enabled
save_config(self.config) 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): def restart_app(self):
import sys import sys
+1 -1
View File
@@ -146,7 +146,7 @@ def main():
# Set the .desktop name on Linux # Set the .desktop name on Linux
if platform.system() == "Linux": if platform.system() == "Linux":
try: try:
app.setDesktopFileName("abogen.desktop") app.setDesktopFileName("abogen")
except AttributeError: except AttributeError:
pass pass
+27 -8
View File
@@ -87,7 +87,8 @@ class DroppableQueueListWidget(QListWidget):
def dragEnterEvent(self, event): def dragEnterEvent(self, event):
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
for url in event.mimeData().urls(): 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.resize(self.size())
self.drag_overlay.setVisible(True) self.drag_overlay.setVisible(True)
event.acceptProposedAction() event.acceptProposedAction()
@@ -98,7 +99,8 @@ class DroppableQueueListWidget(QListWidget):
def dragMoveEvent(self, event): def dragMoveEvent(self, event):
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
for url in event.mimeData().urls(): 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() event.acceptProposedAction()
return return
event.ignore() event.ignore()
@@ -113,7 +115,7 @@ class DroppableQueueListWidget(QListWidget):
file_paths = [ file_paths = [
url.toLocalFile() url.toLocalFile()
for url in event.mimeData().urls() for url in event.mimeData().urls()
if url.isLocalFile() and url.toLocalFile().lower().endswith(".txt") if url.isLocalFile() and (url.toLocalFile().lower().endswith(".txt") or url.toLocalFile().lower().endswith((".srt", ".ass", ".vtt")))
] ]
if file_paths: if file_paths:
self.parent_dialog.add_files_from_paths(file_paths) self.parent_dialog.add_files_from_paths(file_paths)
@@ -151,7 +153,7 @@ class QueueManager(QDialog):
# Add informative instructions at the top # Add informative instructions at the top
instructions = QLabel( instructions = QLabel(
"<h2>How Queue Works?</h2>" "<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. " "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. " "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. " "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) layout.addWidget(instructions)
# Overlay label for empty queue # Overlay label for empty queue
self.empty_overlay = QLabel( 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.listwidget,
) )
self.empty_overlay.setAlignment(Qt.AlignmentFlag.AlignCenter) 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>Subtitle Mode:</b> {getattr(item, 'subtitle_mode', '')}<br>"
f"<b>Output Format:</b> {getattr(item, 'output_format', '')}<br>" f"<b>Output Format:</b> {getattr(item, 'output_format', '')}<br>"
f"<b>Characters:</b> {getattr(item, 'total_char_count', '')}<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 # Add book handler options if present
save_chapters_separately = getattr(item, "save_chapters_separately", None) save_chapters_separately = getattr(item, "save_chapters_separately", None)
@@ -403,6 +407,14 @@ class QueueManager(QDialog):
attrs["replace_single_newlines"] = getattr( attrs["replace_single_newlines"] = getattr(
parent, "replace_single_newlines", False 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 # book handler options
attrs["save_chapters_separately"] = getattr( attrs["save_chapters_separately"] = getattr(
parent, "save_chapters_separately", None parent, "save_chapters_separately", None
@@ -449,6 +461,9 @@ class QueueManager(QDialog):
) )
for attr, value in current_attrs.items(): for attr, value in current_attrs.items():
setattr(item, attr, value) 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 # Read file content and calculate total_char_count using calculate_text_length
try: try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f: 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) == getattr(item, "total_char_count", None)
and getattr(queued_item, "replace_single_newlines", False) and getattr(queued_item, "replace_single_newlines", False)
== getattr(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) and getattr(queued_item, "save_base_path", None)
== getattr(item, "save_base_path", None) == getattr(item, "save_base_path", None)
and getattr(queued_item, "save_chapters_separately", None) and getattr(queued_item, "save_chapters_separately", None)
@@ -506,9 +525,9 @@ class QueueManager(QDialog):
from PyQt6.QtWidgets import QFileDialog from PyQt6.QtWidgets import QFileDialog
from abogen.utils import calculate_text_length # import the function from abogen.utils import calculate_text_length # import the function
# Only allow .txt files # Allow .txt, .srt, .ass, and .vtt files
files, _ = QFileDialog.getOpenFileNames( 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: if not files:
return return
+2
View File
@@ -14,6 +14,8 @@ class QueuedItem:
output_format: str output_format: str
total_char_count: int total_char_count: int
replace_single_newlines: bool = False replace_single_newlines: bool = False
use_silent_gaps: bool = False
subtitle_speed_method: str = "tts"
save_base_path: str = None save_base_path: str = None
save_chapters_separately: bool = None save_chapters_separately: bool = None
merge_chapters_at_end: bool = None merge_chapters_at_end: bool = None