diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a17583..42ceeb2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,12 @@
# 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.
- 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
- Upgraded Abogen's interface from PyQt5 to PyQt6 for better compatibility and long-term support.
diff --git a/README.md b/README.md
index 66e9446..4eb2d60 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-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).
@@ -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.
## `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:
- Set speech speed
- Select a voice (or create a custom voice using voice mixer)
@@ -133,7 +133,7 @@ Here’s Abogen in action: in this demo, it processes ∼3,000 characters of tex
| 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. |
| **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. |
@@ -169,6 +169,8 @@ Here’s 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 cache directory** | Opens the cache directory where converted text files are stored. |
| **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. |
| **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. |
@@ -230,6 +232,27 @@ Similar to chapter markers, it is possible to add metadata tags for `M4B` files.
<>
```
+## `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`
```
# 🇺🇸 'a' => American English, 🇬🇧 'b' => British English
@@ -465,4 +488,4 @@ This project is available under the MIT License - see the [LICENSE](https://gith
> [!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.
-> 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
diff --git a/abogen/constants.py b/abogen/constants.py
index 7092686..cba9342 100644
--- a/abogen/constants.py
+++ b/abogen/constants.py
@@ -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
diff --git a/abogen/conversion.py b/abogen/conversion.py
index f1dc1bd..4ec5b38 100644
--- a/abogen/conversion.py
+++ b/abogen/conversion.py
@@ -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'<]*>>', '', text)
+ # Remove chapter markers
+ text = re.sub(r'<]*>>', '', 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
diff --git a/abogen/gui.py b/abogen/gui.py
index bf7b54c..74e7524 100644
--- a/abogen/gui.py
+++ b/abogen/gui.py
@@ -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
diff --git a/abogen/main.py b/abogen/main.py
index 86982e8..612ab47 100644
--- a/abogen/main.py
+++ b/abogen/main.py
@@ -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
diff --git a/abogen/queue_manager_gui.py b/abogen/queue_manager_gui.py
index 5fe372f..cabc94a 100644
--- a/abogen/queue_manager_gui.py
+++ b/abogen/queue_manager_gui.py
@@ -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(
"How Queue Works?
"
- "You can add text files (.txt) directly using the 'Add files' button below. "
+ "You can add text and subtitle files (.txt, .srt, .ass, .vtt) directly using the 'Add files' button below. "
"To add PDF, EPUB or markdown files, use the input box in the main window and click the 'Add to Queue' button. "
"Each file in the queue keeps the configuration settings active when it was added. "
"Changing the main window configuration afterward does not 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"Subtitle Mode: {getattr(item, 'subtitle_mode', '')}
"
f"Output Format: {getattr(item, 'output_format', '')}
"
f"Characters: {getattr(item, 'total_char_count', '')}
"
- f"Replace Single Newlines: {getattr(item, 'replace_single_newlines', False)}"
+ f"Replace Single Newlines: {getattr(item, 'replace_single_newlines', False)}
"
+ f"Use Silent Gaps: {getattr(item, 'use_silent_gaps', False)}
"
+ f"Speed Method: {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
diff --git a/abogen/queued_item.py b/abogen/queued_item.py
index 17f26d5..fc6c067 100644
--- a/abogen/queued_item.py
+++ b/abogen/queued_item.py
@@ -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