mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
Added subtitle generation support for non-English languages, improvements in code and documentation
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# 1.2.4 (Pre-release)
|
# 1.2.4 (Pre-release)
|
||||||
|
- **Subtitle generation is now available for all languages!** Abogen now supports subtitle generation for non-English languages using duration-based timing. Available modes include `Line`, `Sentence`, and `Sentence + Comma`. (Note: Word-level subtitle modes remain English-only due to Kokoro's timestamp token limitations.)
|
||||||
- Optimized regex compilation and eliminated busy-wait loops.
|
- Optimized regex compilation and eliminated busy-wait loops.
|
||||||
|
- Improvements in code and documentation.
|
||||||
|
|
||||||
# 1.2.3
|
# 1.2.3
|
||||||
- Same as 1.2.2, re-released to fix an issue with subtitle timing when using timestamp-based text files.
|
- Same as 1.2.2, re-released to fix an issue with subtitle timing when using timestamp-based text files.
|
||||||
|
|||||||
@@ -488,7 +488,7 @@ This project is available under the MIT License - see the [LICENSE](https://gith
|
|||||||
## `Star History`
|
## `Star History`
|
||||||
[](https://www.star-history.com/#denizsafak/abogen&Date)
|
[](https://www.star-history.com/#denizsafak/abogen&Date)
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!NOTE]
|
||||||
> 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.
|
> Abogen supports subtitle generation for all languages. However, word-level subtitle modes (e.g., "1 word", "2 words", "3 words", etc.) are only available for English because [Kokoro provides timestamp tokens only for English text](https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py#L383). For non-English languages, Abogen uses a duration-based fallback that supports sentence-level and comma-based subtitle modes ("Line", "Sentence", "Sentence + Comma"). If you need word-level subtitles for other languages, please request that feature in the [Kokoro project](https://github.com/hexgrad/kokoro).
|
||||||
|
|
||||||
> 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
|
> 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
|
||||||
|
|||||||
+1
-4
@@ -61,10 +61,7 @@ SUPPORTED_INPUT_FORMATS = [
|
|||||||
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py
|
# Please refer to: https://github.com/hexgrad/kokoro/blob/6d87f4ae7abc2d14dbc4b3ef2e5f19852e861ac2/kokoro/pipeline.py
|
||||||
# 383 English processing (unchanged)
|
# 383 English processing (unchanged)
|
||||||
# 384 if self.lang_code in 'ab':
|
# 384 if self.lang_code in 'ab':
|
||||||
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = [
|
SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION = list(LANGUAGE_DESCRIPTIONS.keys())
|
||||||
"a",
|
|
||||||
"b",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Voice and sample text constants
|
# Voice and sample text constants
|
||||||
VOICES_INTERNAL = [
|
VOICES_INTERNAL = [
|
||||||
|
|||||||
@@ -690,6 +690,27 @@ class ConversionThread(QThread):
|
|||||||
None if lang_code in self.NO_SPLIT_LANGUAGES else self.DEFAULT_SPLIT_PATTERN
|
None if lang_code in self.NO_SPLIT_LANGUAGES else self.DEFAULT_SPLIT_PATTERN
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Override split pattern for non-English languages when sentence-based subtitles are requested
|
||||||
|
# This ensures we get sentence-level audio chunks since we don't have word-level timestamps
|
||||||
|
if (
|
||||||
|
self.subtitle_mode in ["Sentence", "Sentence + Comma"]
|
||||||
|
and lang_code not in ["a", "b"]
|
||||||
|
):
|
||||||
|
if lang_code in self.NO_SPLIT_LANGUAGES:
|
||||||
|
# Split by CJK punctuation (keeping it)
|
||||||
|
if self.subtitle_mode == "Sentence + Comma":
|
||||||
|
# Use comma pattern if available
|
||||||
|
self.split_pattern = r"(?<=[。!?、,])"
|
||||||
|
else:
|
||||||
|
self.split_pattern = r"(?<=[。!?])"
|
||||||
|
else:
|
||||||
|
# Split by sentence endings (keeping punctuation) or newlines
|
||||||
|
if self.subtitle_mode == "Sentence + Comma":
|
||||||
|
# Include commas in split pattern
|
||||||
|
self.split_pattern = r"(?<=[.!?,])\s+|\n+"
|
||||||
|
else:
|
||||||
|
self.split_pattern = r"(?<=[.!?])\s+|\n+"
|
||||||
|
|
||||||
def _stream_audio_in_chunks(
|
def _stream_audio_in_chunks(
|
||||||
self, segments, process_func, progress_prefix="Processing"
|
self, segments, process_func, progress_prefix="Processing"
|
||||||
):
|
):
|
||||||
@@ -1419,6 +1440,20 @@ class ConversionThread(QThread):
|
|||||||
# Subtitle logic
|
# Subtitle logic
|
||||||
if self.subtitle_mode != "Disabled":
|
if self.subtitle_mode != "Disabled":
|
||||||
tokens_list = getattr(result, "tokens", [])
|
tokens_list = getattr(result, "tokens", [])
|
||||||
|
|
||||||
|
# Fallback for languages without token support (non-English)
|
||||||
|
# Create a single token representing the entire segment duration
|
||||||
|
if not tokens_list and result.graphemes:
|
||||||
|
|
||||||
|
class FakeToken:
|
||||||
|
def __init__(self, text, start, end):
|
||||||
|
self.text = text
|
||||||
|
self.start_ts = start
|
||||||
|
self.end_ts = end
|
||||||
|
self.whitespace = ""
|
||||||
|
|
||||||
|
tokens_list = [FakeToken(result.graphemes, 0, chunk_dur)]
|
||||||
|
|
||||||
tokens_with_timestamps = []
|
tokens_with_timestamps = []
|
||||||
chapter_tokens_with_timestamps = []
|
chapter_tokens_with_timestamps = []
|
||||||
|
|
||||||
|
|||||||
+94
-29
@@ -390,7 +390,7 @@ class InputBox(QLabel):
|
|||||||
window = self.window()
|
window = self.window()
|
||||||
if hasattr(window, "subtitle_combo"):
|
if hasattr(window, "subtitle_combo"):
|
||||||
# Only enable if language supports it
|
# Only enable if language supports it
|
||||||
current_lang = getattr(window, "lang_code", "a")
|
current_lang = getattr(window, "selected_lang", "a")
|
||||||
window.subtitle_combo.setEnabled(
|
window.subtitle_combo.setEnabled(
|
||||||
current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
current_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
||||||
)
|
)
|
||||||
@@ -824,7 +824,7 @@ class abogen(QWidget):
|
|||||||
self.use_gpu = self.config.get(
|
self.use_gpu = self.config.get(
|
||||||
"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", True)
|
||||||
self.use_silent_gaps = self.config.get("use_silent_gaps", True)
|
self.use_silent_gaps = self.config.get("use_silent_gaps", True)
|
||||||
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
|
self.subtitle_speed_method = self.config.get("subtitle_speed_method", "tts")
|
||||||
self._pending_close_event = None
|
self._pending_close_event = None
|
||||||
@@ -1062,9 +1062,6 @@ class abogen(QWidget):
|
|||||||
)
|
)
|
||||||
self.subtitle_combo.setCurrentText(self.subtitle_mode)
|
self.subtitle_combo.setCurrentText(self.subtitle_mode)
|
||||||
self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed)
|
self.subtitle_combo.currentTextChanged.connect(self.on_subtitle_mode_changed)
|
||||||
# Enable/disable subtitle options based on selected language (profile or voice)
|
|
||||||
enable = self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
|
||||||
self.subtitle_combo.setEnabled(enable)
|
|
||||||
subtitle_layout.addWidget(self.subtitle_combo)
|
subtitle_layout.addWidget(self.subtitle_combo)
|
||||||
controls_layout.addLayout(subtitle_layout)
|
controls_layout.addLayout(subtitle_layout)
|
||||||
|
|
||||||
@@ -1146,10 +1143,10 @@ class abogen(QWidget):
|
|||||||
except Exception:
|
except Exception:
|
||||||
# Fail-safe: don't crash UI if model manipulation isn't supported on some platforms
|
# Fail-safe: don't crash UI if model manipulation isn't supported on some platforms
|
||||||
pass
|
pass
|
||||||
# Enable/disable subtitle format based on selected language
|
|
||||||
self.subtitle_format_combo.setEnabled(
|
# Enable/disable subtitle options based on selected language (profile or voice)
|
||||||
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
self.update_subtitle_options_availability()
|
||||||
)
|
|
||||||
controls_layout.addLayout(subtitle_format_layout)
|
controls_layout.addLayout(subtitle_format_layout)
|
||||||
|
|
||||||
# Replace single newlines dropdown (acts like checkbox)
|
# Replace single newlines dropdown (acts like checkbox)
|
||||||
@@ -1590,19 +1587,69 @@ class abogen(QWidget):
|
|||||||
self.config["speed"] = s
|
self.config["speed"] = s
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
|
|
||||||
|
def update_subtitle_options_availability(self):
|
||||||
|
"""
|
||||||
|
Update the enabled state of subtitle options based on the selected language.
|
||||||
|
For non-English languages, only sentence-based and line-based modes are supported.
|
||||||
|
"""
|
||||||
|
# Check if current file is a subtitle file
|
||||||
|
is_subtitle_input = False
|
||||||
|
if self.selected_file and self.selected_file.lower().endswith(
|
||||||
|
(".srt", ".ass", ".vtt")
|
||||||
|
):
|
||||||
|
is_subtitle_input = True
|
||||||
|
|
||||||
|
if self.selected_lang not in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION:
|
||||||
|
self.subtitle_combo.setEnabled(False)
|
||||||
|
self.subtitle_format_combo.setEnabled(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only enable subtitle_combo if it's NOT a subtitle input
|
||||||
|
self.subtitle_combo.setEnabled(not is_subtitle_input)
|
||||||
|
self.subtitle_format_combo.setEnabled(True)
|
||||||
|
|
||||||
|
is_english = self.selected_lang in ["a", "b"]
|
||||||
|
|
||||||
|
# Items to keep enabled for non-English
|
||||||
|
allowed_modes = ["Disabled", "Line", "Sentence", "Sentence + Comma"]
|
||||||
|
|
||||||
|
model = self.subtitle_combo.model()
|
||||||
|
for i in range(self.subtitle_combo.count()):
|
||||||
|
text = self.subtitle_combo.itemText(i)
|
||||||
|
item = model.item(i)
|
||||||
|
if not item:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_english:
|
||||||
|
item.setEnabled(True)
|
||||||
|
else:
|
||||||
|
if text in allowed_modes:
|
||||||
|
item.setEnabled(True)
|
||||||
|
else:
|
||||||
|
item.setEnabled(False)
|
||||||
|
|
||||||
|
# If current selection is disabled, switch to a valid one
|
||||||
|
current_text = self.subtitle_combo.currentText()
|
||||||
|
current_idx = self.subtitle_combo.currentIndex()
|
||||||
|
current_item = model.item(current_idx)
|
||||||
|
|
||||||
|
if current_item and not current_item.isEnabled():
|
||||||
|
# Switch to "Sentence" if available, else "Disabled"
|
||||||
|
sentence_idx = self.subtitle_combo.findText("Sentence")
|
||||||
|
if sentence_idx >= 0:
|
||||||
|
self.subtitle_combo.setCurrentIndex(sentence_idx)
|
||||||
|
else:
|
||||||
|
self.subtitle_combo.setCurrentIndex(0) # Disabled
|
||||||
|
|
||||||
|
self.subtitle_mode = self.subtitle_combo.currentText()
|
||||||
|
|
||||||
def on_voice_changed(self, index):
|
def on_voice_changed(self, index):
|
||||||
voice = self.voice_combo.itemData(index)
|
voice = self.voice_combo.itemData(index)
|
||||||
self.selected_voice, self.selected_lang = voice, voice[0]
|
self.selected_voice, self.selected_lang = voice, voice[0]
|
||||||
self.config["selected_voice"] = voice
|
self.config["selected_voice"] = voice
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
# Enable/disable subtitle options based on language
|
# Enable/disable subtitle options based on language
|
||||||
if self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION:
|
self.update_subtitle_options_availability()
|
||||||
self.subtitle_combo.setEnabled(True)
|
|
||||||
self.subtitle_format_combo.setEnabled(True)
|
|
||||||
self.subtitle_mode = self.subtitle_combo.currentText()
|
|
||||||
else:
|
|
||||||
self.subtitle_combo.setEnabled(False)
|
|
||||||
self.subtitle_format_combo.setEnabled(False)
|
|
||||||
|
|
||||||
def on_voice_combo_changed(self, index):
|
def on_voice_combo_changed(self, index):
|
||||||
data = self.voice_combo.itemData(index)
|
data = self.voice_combo.itemData(index)
|
||||||
@@ -1624,12 +1671,7 @@ class abogen(QWidget):
|
|||||||
self.config.pop("selected_voice", None)
|
self.config.pop("selected_voice", None)
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
# enable subtitles based on profile language
|
# enable subtitles based on profile language
|
||||||
self.subtitle_combo.setEnabled(
|
self.update_subtitle_options_availability()
|
||||||
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
|
||||||
)
|
|
||||||
self.subtitle_format_combo.setEnabled(
|
|
||||||
self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.mixed_voice_state = None
|
self.mixed_voice_state = None
|
||||||
self.selected_profile_name = None
|
self.selected_profile_name = None
|
||||||
@@ -1638,13 +1680,7 @@ class abogen(QWidget):
|
|||||||
if "selected_profile_name" in self.config:
|
if "selected_profile_name" in self.config:
|
||||||
del self.config["selected_profile_name"]
|
del self.config["selected_profile_name"]
|
||||||
save_config(self.config)
|
save_config(self.config)
|
||||||
if self.selected_lang in SUPPORTED_LANGUAGES_FOR_SUBTITLE_GENERATION:
|
self.update_subtitle_options_availability()
|
||||||
self.subtitle_combo.setEnabled(True)
|
|
||||||
self.subtitle_format_combo.setEnabled(True)
|
|
||||||
self.subtitle_mode = self.subtitle_combo.currentText()
|
|
||||||
else:
|
|
||||||
self.subtitle_combo.setEnabled(False)
|
|
||||||
self.subtitle_format_combo.setEnabled(False)
|
|
||||||
|
|
||||||
def update_subtitle_combo_for_profile(self, profile_name):
|
def update_subtitle_combo_for_profile(self, profile_name):
|
||||||
from abogen.voice_profiles import load_profiles
|
from abogen.voice_profiles import load_profiles
|
||||||
@@ -2790,6 +2826,33 @@ class abogen(QWidget):
|
|||||||
self.conversion_thread.cancel()
|
self.conversion_thread.cancel()
|
||||||
self.conversion_thread.wait()
|
self.conversion_thread.wait()
|
||||||
|
|
||||||
|
def cleanup_preview_threads(self):
|
||||||
|
# Stop preview generation thread
|
||||||
|
if (
|
||||||
|
hasattr(self, "preview_thread")
|
||||||
|
and self.preview_thread is not None
|
||||||
|
and self.preview_thread.isRunning()
|
||||||
|
):
|
||||||
|
self.preview_thread.terminate()
|
||||||
|
self.preview_thread.wait()
|
||||||
|
|
||||||
|
# Stop audio playback thread
|
||||||
|
if (
|
||||||
|
hasattr(self, "play_audio_thread")
|
||||||
|
and self.play_audio_thread is not None
|
||||||
|
and self.play_audio_thread.isRunning()
|
||||||
|
):
|
||||||
|
self.play_audio_thread.stop()
|
||||||
|
self.play_audio_thread.wait()
|
||||||
|
|
||||||
|
# Cleanup pygame mixer if initialized
|
||||||
|
try:
|
||||||
|
import pygame
|
||||||
|
if pygame.mixer.get_init():
|
||||||
|
pygame.mixer.quit()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
if self.is_converting:
|
if self.is_converting:
|
||||||
box = QMessageBox(self)
|
box = QMessageBox(self)
|
||||||
@@ -2804,11 +2867,13 @@ class abogen(QWidget):
|
|||||||
box.setDefaultButton(QMessageBox.StandardButton.No)
|
box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||||
if box.exec() == QMessageBox.StandardButton.Yes:
|
if box.exec() == QMessageBox.StandardButton.Yes:
|
||||||
self.cleanup_conversion_thread()
|
self.cleanup_conversion_thread()
|
||||||
|
self.cleanup_preview_threads()
|
||||||
event.accept()
|
event.accept()
|
||||||
else:
|
else:
|
||||||
event.ignore()
|
event.ignore()
|
||||||
else:
|
else:
|
||||||
self.cleanup_conversion_thread()
|
self.cleanup_conversion_thread()
|
||||||
|
self.cleanup_preview_threads()
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
def show_chapter_options_dialog(self, chapter_count):
|
def show_chapter_options_dialog(self, chapter_count):
|
||||||
|
|||||||
Reference in New Issue
Block a user