mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
Add subtitle file voicing feature, improvements in code and documentation
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 @@ Here’s 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 @@ 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 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
@@ -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
|
||||||
|
|||||||
+764
-73
@@ -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)
|
||||||
|
self._timer = None
|
||||||
|
self._button_box = None
|
||||||
|
|
||||||
# Add informational label
|
def add_countdown_and_buttons(self):
|
||||||
layout.addWidget(QLabel(f"Detected {chapter_count} chapters in the text file."))
|
"""Add countdown label and OK button - call this after adding custom content"""
|
||||||
layout.addWidget(QLabel("How would you like to process these chapters?"))
|
self.countdown_label = QLabel(f"Auto-accepting in {self.countdown_seconds} seconds...")
|
||||||
|
|
||||||
# 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_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,12 +465,120 @@ 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,
|
||||||
file_name,
|
file_name,
|
||||||
@@ -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"
|
||||||
@@ -355,6 +737,21 @@ class ConversionThread(QThread):
|
|||||||
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"):
|
||||||
self.log_updated.emit(
|
self.log_updated.emit(
|
||||||
@@ -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,6 +1603,264 @@ 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"]
|
||||||
@@ -1187,6 +1868,10 @@ class ConversionThread(QThread):
|
|||||||
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"""
|
||||||
metadata_options = []
|
metadata_options = []
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user