Compare commits

..
3 Commits
Author SHA1 Message Date
Deniz Şafak ffac4a4da9 fix(subtitles): enable "N words" and karaoke modes in PyQt
Three related fixes:

- make_subtitle_writer: accept word-count modes ("N words") in
  create_subtitle_writer. SubtitleMode("5 words") raised ValueError,
  which make_subtitle_writer swallowed and returned None, so PyQt never
  created or wrote the subtitle file when an "N words" mode was selected.
  Unknown modes now fall back to SubtitleMode.SENTENCE (writers only
  branch on SENTENCE_HIGHLIGHT). Regression test added.

- gui.py: refresh subtitle combo item availability after initial
  voice/profile selection. update_subtitle_options_availability() ran
  during initUI with selected_lang=None for a profile, taking the
  non-English branch and disabling Highlighting/N-words items; the fix-up
  never re-ran because setCurrentIndex on an already-current index emits
  no signal. Now called once more after the profile/voice language is
  resolved.

- AssWriter: stop discarding per-word karaoke timing. _add_karaoke_tags
  unconditionally replaced entry text with uniform {\\k100} tags,
  destroying the real per-word {\\kf} timings from
  _process_karaoke_highlighting. Only synthesize simplified tags when the
  text has no karaoke tags. Regression test added.
2026-08-20 23:27:49 +03:00
Deniz Şafak 5432de7ac5 fix(segmentation): process TTS segments per sentence, fix all subtitle modes
Sentence modes processed all text as a whole: Pipeline.__call__ merged every
engine segment back into one (whole text, no per-token timings), producing a
single giant subtitle and whole-text progress logs.

- tts_plugin/types: add TokenTiming, AudioSegment, SynthesizedAudio.segments
- tts_plugin/utils: Pipeline yields one Segment per engine segment (with
  tokens); merged fallback only when engine provides none
- kokoro engine: expose per-segment graphemes/audio + per-word token timings
- supertonic engine: expose per-segment graphemes/audio (no tokens)
- split_pattern: English Sentence/Sentence+Comma engine split is newline-only
  (boundaries applied at subtitle time via spaCy); non-English Sentence+Comma
  with spaCy ON uses spaCy pre-segmentation + newline engine split (no
  commas); spaCy-off fallback keeps comma pattern
- tts_segments: restore inter-segment whitespace on real per-word token
  boundaries only (never FakeToken fallbacks)
- _to_language_enum: accept Language enum input (str(enum) is "Language.ES",
  silently resolved to EN_US and disabled spaCy pre-TTS for every language
  in WebUI)
- pyqt/conversion, utils: replace print with logging
- add AGENTS.md documenting the segmentation/subtitle contract for future
  sessions
- tests: update English split-pattern expectations (1566 passing)
2026-08-20 23:00:15 +03:00
Deniz Şafak 823f5be029 fix(pyqt): make app close fast and crash-free
Clicking the window close button froze the app: closeEvent ran the full
process cleanup (engine disposal, CUDA flush, subprocess termination) and
unbounded thread joins synchronously on the GUI thread before the window
could start closing, and a 3.5s delay came from flush_cuda importing
torch even when it was never loaded.

- closeEvent no longer runs cleanup synchronously; the aboutToQuit hook,
  which was never connected (registered before QApplication existed), now
  runs it after the window is gone
- bound thread waits in cleanup_conversion_thread/cleanup_preview_threads
  with a terminate() fallback so closing never hangs
- flush_cuda skips torch work when torch was never imported
- restore the default Qt message handler before sys.exit; the custom
  Python handler was invoked during interpreter teardown and caused a
  SIGSEGV after shutdown cleanups finished
- log and time the close sequence (closeEvent steps + each shutdown hook)
2026-08-20 22:00:08 +03:00
16 changed files with 351 additions and 56 deletions
+82
View File
@@ -0,0 +1,82 @@
# AGENTS.md — Segmentation & Subtitle System Contract
This document is the source of truth for how text is split for **voice
processing** (TTS engine segmentation) and **subtitle processing**, across
languages, TTS engines, and subtitle modes. It was written after a bug where
sentence modes "processed all text as a whole" (one merged engine segment →
one giant subtitle). **Do not change this behavior without updating this
table.**
## Voice processing — split pattern passed to the TTS engine
`get_split_pattern(language, mode)` in `abogen/domain/split_pattern.py` is the
default; the spaCy pre-TTS path overrides it. Both UIs must stay in sync:
`spacy_pre_tts_segmentation` (`abogen/domain/conversion_pipeline.py`, WebUI)
and the inline branch in `abogen/pyqt/conversion.py` (~line 860, PyQt).
| Subtitle mode | English (en-US/en-GB) | Non-English, spaCy ON | Non-English, spaCy OFF | CJK (ja/zh) |
|---|---|---|---|---|
| Disabled | `\n` | spaCy pre-split, engine `\n` | `\n+` | `(?<=[.!?؟。!?।])\s*\|\n+` |
| Line | `\n` | spaCy pre-split, engine `\n` | `\n` | `(?<=[.!?؟。!?।])\s*\|\n+` |
| Sentence | `\n` | spaCy pre-split, engine `\n` | `(?<=[.!?؟。!?।])\s+\|\n+` | `(?<=[.!?؟。!?।])\s*\|\n+` |
| Sentence + Comma | `\n` | spaCy pre-split, engine `\n` | `(?<=[.!?,؟。!?،،、।])\s+\|\n+` (commas kept) | `(?<=[.!?,؟。!?،،、।])\s*\|\n+` |
| Sentence + Highlighting | `\n+` | `\n+` | `\n+` | `\n+` |
| N words ("5 words") | `\n` (→ Disabled) | `\n+` | `\n+` | Disabled CJK pattern |
Rules baked into this table:
- **English voice splitting is ALWAYS newline-only** for Disabled, Line,
Sentence, and Sentence + Comma. English sentence/comma boundaries are
produced ONLY at subtitle time (spaCy post-TTS / regex fallback). Never add
punctuation to the English engine pattern.
- **Non-English + spaCy ON**: spaCy pre-segments the text (pre-TTS); the
engine pattern is `\n` for Sentence AND Sentence + Comma — **never commas**.
spaCy is skipped when the toggle is off, mode is Disabled/Line, or input is
a subtitle file.
- **Non-English + spaCy OFF** (toggle off, spaCy failure, subtitle input): the
default pattern is used — Sentence + Comma KEEPS its commas here. This is
the intentional fallback, not a bug.
- CJK: punctuation-based patterns for Disabled/Line (historical); spacing is
`\s*` (no spaces needed between CJK chars).
- Engine-level extra chunking (applies after the pattern): kokoro English
re-chunks at ~510 phonemes; kokoro non-English at ~400 chars; supertonic
caps each part at 300 chars.
## Subtitle processing — post-TTS, from tokens
| Mode | Behavior |
|---|---|
| Disabled | no subtitles |
| Line | one entry per TTS segment (line) |
| Sentence | sentence boundaries: English → spaCy; others → regex on `[.!?…]` |
| Sentence + Comma | sentence + comma boundaries at subtitle time (both languages) — commas never affect voice |
| Sentence + Highlighting | karaoke `{\kf…}` per word, grouped by sentence |
| N words | groups of N words by whitespace counting |
Token granularity (timing quality): kokoro English emits **per-word tokens**
with timestamps; kokoro non-English and supertonic emit **no tokens** → each
engine segment becomes one FakeToken, split by regex with proportional timing
when it contains multiple sentences.
## Hard invariants (breaking these reintroduces the original bug)
1. `Pipeline.__call__` (`abogen/tts_plugin/utils.py`) must yield ONE `Segment`
per engine segment (with tokens) — never merge segments back into the
whole text. `SynthesizedAudio.segments` carries the per-segment data;
engines expose it in `plugins/kokoro/engine.py` and
`plugins/supertonic/engine.py`.
2. `tts_segments` (`abogen/domain/conversion_pipeline.py`) restores trailing
whitespace on segment-boundary tokens ONLY for real per-word tokens, never
for FakeToken fallbacks.
3. `_to_language_enum` must return `lang_code` as-is when it is already a
`Language` enum (`str(Language.ES)` is `"Language.ES"`, which silently
resolved to EN_US and disabled spaCy pre-TTS for every language in WebUI).
4. English must never use spaCy for PRE-TTS segmentation — only for subtitles.
## Guarded by tests
- `tests/test_split_pattern.py` — English newline-only; non-English sentence
patterns; CJK behavior.
- `tests/test_domain_conversion_pipeline.py``tts_segments` / spaCy
segmentation helpers.
- Full suite: `python -m pytest tests/ -q` (expect 1566+ passing).
+6 -1
View File
@@ -11,6 +11,7 @@ Called by shutdown.py at process exit and by run_conversion() per-conversion.
from __future__ import annotations from __future__ import annotations
import gc import gc
import sys
from typing import Callable from typing import Callable
_UI_CLEANUPS: list[Callable[[], None]] = [] _UI_CLEANUPS: list[Callable[[], None]] = []
@@ -19,8 +20,12 @@ _UI_CLEANUPS: list[Callable[[], None]] = []
def flush_cuda() -> None: def flush_cuda() -> None:
"""Run GC and release CUDA cache. Safe to call multiple times.""" """Run GC and release CUDA cache. Safe to call multiple times."""
gc.collect() gc.collect()
# Skip entirely if torch was never imported — importing it here just to
# check would add several seconds to shutdown with nothing to flush.
if "torch" not in sys.modules:
return
try: try:
import torch torch = sys.modules["torch"]
if torch.cuda.is_available(): if torch.cuda.is_available():
torch.cuda.empty_cache() torch.cuda.empty_cache()
torch.cuda.ipc_collect() torch.cuda.ipc_collect()
+26 -9
View File
@@ -60,7 +60,7 @@ def spacy_pre_tts_segmentation(
text_segments is a list of sentences (always at least one element). text_segments is a list of sentences (always at least one element).
active_split_pattern is the regex to use for TTS backend splitting. active_split_pattern is the regex to use for TTS backend splitting.
""" """
from abogen.domain.split_pattern import PUNCTUATION_COMMAS, get_split_pattern from abogen.domain.split_pattern import get_split_pattern
def _log(msg: str) -> None: def _log(msg: str) -> None:
if log_callback: if log_callback:
@@ -99,20 +99,19 @@ def spacy_pre_tts_segmentation(
_log(f"spaCy: Text segmented into {len(spacy_sentences)} sentences...") _log(f"spaCy: Text segmented into {len(spacy_sentences)} sentences...")
# Compute split_pattern override based on subtitle mode # spaCy already split at sentence boundaries; the engine only needs to
spacing_pattern = r"\s*" if lang_enum in _CJK_LANGS else r"\s+" # split on newlines. Commas are never used in the engine split pattern
# for non-English (Sentence + Comma splits at commas only at subtitle
if subtitle_mode_str == "Sentence + Comma": # time, like English).
active_split = r"(?<=[{}]){}|\n+".format(PUNCTUATION_COMMAS, spacing_pattern) active_split = "\n"
else:
# Sentence mode: spaCy already split, only split on newlines
active_split = "\n"
return spacy_sentences, active_split return spacy_sentences, active_split
def _to_language_enum(lang_code: Any) -> Language: def _to_language_enum(lang_code: Any) -> Language:
"""Convert lang_code to Language enum (ISO code or Language enum).""" """Convert lang_code to Language enum (ISO code or Language enum)."""
if isinstance(lang_code, Language):
return lang_code
try: try:
return Language.from_str(str(lang_code)) return Language.from_str(str(lang_code))
except ValueError: except ValueError:
@@ -174,6 +173,8 @@ def tts_segments(
segment_iter = backend(text, **kwargs) segment_iter = backend(text, **kwargs)
chunk_start = current_time chunk_start = current_time
prev_tokens: Optional[List[Dict[str, Any]]] = None
prev_was_fallback = True
for segment in segment_iter: for segment in segment_iter:
graphemes_raw = getattr(segment, "graphemes", "") or "" graphemes_raw = getattr(segment, "graphemes", "") or ""
@@ -186,8 +187,10 @@ def tts_segments(
duration = len(audio) / SAMPLE_RATE duration = len(audio) / SAMPLE_RATE
tokens_list = getattr(segment, "tokens", []) tokens_list = getattr(segment, "tokens", [])
was_fallback = False
if not tokens_list and graphemes: if not tokens_list and graphemes:
tokens_list = [FakeToken(graphemes, 0, duration)] tokens_list = [FakeToken(graphemes, 0, duration)]
was_fallback = True
tokens = [ tokens = [
{ {
@@ -199,6 +202,18 @@ def tts_segments(
for tok in tokens_list for tok in tokens_list
] ]
# When the engine splits text on a punctuation pattern, the
# whitespace between segments is consumed by the split. Restore a
# trailing space on the boundary token of the previous segment so
# subtitle processing sees the original spacing (only for real
# per-word tokens; FakeToken fallbacks split via their own logic).
if (
not prev_was_fallback
and prev_tokens
and not prev_tokens[-1].get("whitespace")
):
prev_tokens[-1]["whitespace"] = " "
yield SegmentResult( yield SegmentResult(
graphemes=graphemes, graphemes=graphemes,
audio=audio, audio=audio,
@@ -207,6 +222,8 @@ def tts_segments(
tokens=tokens, tokens=tokens,
) )
prev_tokens = tokens
prev_was_fallback = was_fallback
chunk_start += duration chunk_start += duration
+11 -2
View File
@@ -27,9 +27,18 @@ def get_split_pattern(language: Language, subtitle_mode: str) -> str:
except ValueError: except ValueError:
mode = SubtitleMode.DISABLED mode = SubtitleMode.DISABLED
# For English, always use newline splitting only # English: spaCy is NOT used for pre-TTS segmentation (it is only used
# for post-TTS subtitle boundaries), so sentence boundaries for English
# are applied at subtitle time, not in the TTS engine. Disabled, Line,
# Sentence, and Sentence + Comma all keep newline-only engine splitting.
if language in (Language.EN_US, Language.EN_GB): if language in (Language.EN_US, Language.EN_GB):
return "\n" if mode in (
SubtitleMode.DISABLED,
SubtitleMode.LINE,
SubtitleMode.SENTENCE,
SubtitleMode.SENTENCE_COMMA,
):
return "\n"
# Determine spacing pattern based on language # Determine spacing pattern based on language
spacing = r"\s*" if language.is_cjk else r"\s+" spacing = r"\s*" if language.is_cjk else r"\s+"
+18 -3
View File
@@ -220,8 +220,10 @@ class AssWriter(SubtitleWriter):
style = "Default" style = "Default"
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT: if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
# Add karaoke tags for highlighting # Entries from process_subtitle_tokens already carry per-word
text = self._add_karaoke_tags(text) # {\kf...} timing; only synthesize simplified tags when absent.
if "{\\k" not in text:
text = self._add_karaoke_tags(text)
style = "Highlight" style = "Highlight"
alignment_tag = r"{\an5}" if self._is_centered else "" alignment_tag = r"{\an5}" if self._is_centered else ""
@@ -248,6 +250,19 @@ class AssWriter(SubtitleWriter):
return f"{hours}:{minutes:02d}:{secs:05.2f}" return f"{hours}:{minutes:02d}:{secs:05.2f}"
def _coerce_mode(mode: str) -> SubtitleMode:
"""Parse a subtitle mode, tolerating word-count strings like "5 words".
Word-count modes are grouped upstream (subtitle_generation) and the writer
only branches on SubtitleMode.SENTENCE_HIGHLIGHT, so any non-highlight
fallback is behaviorally equivalent for the writers.
"""
try:
return SubtitleMode(mode)
except ValueError:
return SubtitleMode.SENTENCE
def create_subtitle_writer( def create_subtitle_writer(
path: Path, path: Path,
format: str, format: str,
@@ -257,7 +272,7 @@ def create_subtitle_writer(
) -> SubtitleWriter: ) -> SubtitleWriter:
"""Factory function to create subtitle writer.""" """Factory function to create subtitle writer."""
fmt = SubtitleFormat(format.lower()) fmt = SubtitleFormat(format.lower())
mode = SubtitleMode(mode) mode = _coerce_mode(mode)
align = SubtitleAlignment(alignment.lower()) align = SubtitleAlignment(alignment.lower())
config = SubtitleConfig( config = SubtitleConfig(
+13 -16
View File
@@ -1,5 +1,6 @@
import os import os
import time import time
import logging
import hashlib # For generating unique cache filenames import hashlib # For generating unique cache filenames
from pathlib import Path from pathlib import Path
from platformdirs import user_desktop_dir from platformdirs import user_desktop_dir
@@ -50,6 +51,8 @@ import abogen.hf_tracker as hf_tracker
import static_ffmpeg import static_ffmpeg
import threading # for efficient waiting import threading # for efficient waiting
logger = logging.getLogger(__name__)
# Configuration constants # Configuration constants
@@ -64,7 +67,6 @@ from abogen.subtitle_utils import (
sanitize_name_for_os, sanitize_name_for_os,
split_text_by_voice_markers split_text_by_voice_markers
) )
from abogen.domain.split_pattern import PUNCTUATION_COMMAS
class CountdownDialog(QDialog): class CountdownDialog(QDialog):
"""Base dialog with auto-accept countdown functionality""" """Base dialog with auto-accept countdown functionality"""
@@ -348,7 +350,7 @@ class ConversionThread(QThread):
return samples_processed return samples_processed
def run(self): def run(self):
print( logger.info(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n" f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\nFile: {self.file_name}\nSubtitle mode: {self.subtitle_mode}\nOutput format: {self.output_format}\nSave option: {self.save_option}\n"
) )
try: try:
@@ -873,7 +875,6 @@ class ConversionThread(QThread):
) )
spacy_sentences = None spacy_sentences = None
active_split_pattern = self.split_pattern active_split_pattern = self.split_pattern
spacing_pattern = r"\s*" if self.lang_code in (Language.JA, Language.ZH) else r"\s+"
# Pre-load spaCy model for English if it will be needed for subtitle generation # Pre-load spaCy model for English if it will be needed for subtitle generation
if ( if (
@@ -914,15 +915,11 @@ class ConversionThread(QThread):
"grey", "grey",
) )
) )
# For Sentence + Comma mode, still split on commas within spaCy sentences # spaCy already split at sentence boundaries; the
if self.subtitle_mode == "Sentence + Comma": # engine only splits on newlines. Commas are never
active_split_pattern = r"(?<=[{}]){}|\n+".format( # used in the engine split pattern (Sentence +
PUNCTUATION_COMMAS, spacing_pattern # Comma splits at commas only at subtitle time).
) active_split_pattern = "\n"
else:
active_split_pattern = (
"\n" # Use newline splitting for Sentence mode
)
else: else:
self.log_updated.emit( self.log_updated.emit(
("\nspaCy: Fallback to default segmentation...", "grey") ("\nspaCy: Fallback to default segmentation...", "grey")
@@ -933,10 +930,10 @@ class ConversionThread(QThread):
# Print active split pattern used by the TTS engine once for this batch # Print active split pattern used by the TTS engine once for this batch
try: try:
print(f"Using split pattern: {active_split_pattern!r}") logger.info(f"Using split pattern: {active_split_pattern!r}")
except Exception: except Exception:
# Print must never break processing # Logging must never break processing
print("Using split pattern: (unprintable)") logger.warning("Using split pattern: (unprintable)")
for text_segment in text_segments: for text_segment in text_segments:
def _qt_check_cancel() -> bool: def _qt_check_cancel() -> bool:
@@ -1445,7 +1442,7 @@ class VoicePreviewThread(QThread):
return os.path.join(self.cache_dir, filename) return os.path.join(self.cache_dir, filename)
def run(self): def run(self):
print( logger.info(
f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n" f"\nVoice: {self.voice}\nLanguage: {self.lang_code}\nSpeed: {self.speed}\nGPU: {self.use_gpu}\n"
) )
+32 -8
View File
@@ -5,9 +5,12 @@ import tempfile
import platform import platform
import base64 import base64
import re import re
import logging
from abogen.pyqt.queue_manager_gui import QueueManager from abogen.pyqt.queue_manager_gui import QueueManager
from abogen.pyqt.queued_item import QueuedItem from abogen.pyqt.queued_item import QueuedItem
_log = logging.getLogger("abogen.gui")
import abogen.hf_tracker as hf_tracker import abogen.hf_tracker as hf_tracker
import hashlib # Added for cache path generation import hashlib # Added for cache path generation
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
@@ -1016,6 +1019,7 @@ class abogen(QWidget):
self.selected_lang = ( self.selected_lang = (
language_for_voice_id(entry[0]) if entry and entry[0] else Language.EN_US language_for_voice_id(entry[0]) if entry and entry[0] else Language.EN_US
) )
self.update_subtitle_options_availability()
if self.save_option == "Choose output folder" and self.selected_output_folder: if self.save_option == "Choose output folder" and self.selected_output_folder:
self.save_path_label.setText(self.selected_output_folder) self.save_path_label.setText(self.selected_output_folder)
self.save_path_row_widget.show() self.save_path_row_widget.show()
@@ -3169,14 +3173,25 @@ class abogen(QWidget):
save_config(self.config) save_config(self.config)
def cleanup_conversion_thread(self): def cleanup_conversion_thread(self):
# Stop conversion thread # Stop conversion thread (bounded wait so closing never hangs)
if ( if (
hasattr(self, "conversion_thread") hasattr(self, "conversion_thread")
and self.conversion_thread is not None and self.conversion_thread is not None
and self.conversion_thread.isRunning() and self.conversion_thread.isRunning()
): ):
_log.info("Close: stopping conversion thread")
start = time.perf_counter()
self.conversion_thread.cancel() self.conversion_thread.cancel()
self.conversion_thread.wait() if not self.conversion_thread.wait(2000):
_log.warning("Close: conversion thread did not stop in 2s, terminating")
self.conversion_thread.terminate()
self.conversion_thread.wait(1000)
_log.info(
"Close: conversion thread stopped in %.2fs",
time.perf_counter() - start,
)
else:
_log.info("Close: no running conversion thread")
def cleanup_preview_threads(self): def cleanup_preview_threads(self):
# Stop preview generation thread # Stop preview generation thread
@@ -3185,8 +3200,13 @@ class abogen(QWidget):
and self.preview_thread is not None and self.preview_thread is not None
and self.preview_thread.isRunning() and self.preview_thread.isRunning()
): ):
_log.info("Close: terminating preview thread")
start = time.perf_counter()
self.preview_thread.terminate() self.preview_thread.terminate()
self.preview_thread.wait() self.preview_thread.wait(1000)
_log.info(
"Close: preview thread stopped in %.2fs", time.perf_counter() - start
)
# Stop audio playback thread # Stop audio playback thread
if ( if (
@@ -3194,8 +3214,13 @@ class abogen(QWidget):
and self.play_audio_thread is not None and self.play_audio_thread is not None
and self.play_audio_thread.isRunning() and self.play_audio_thread.isRunning()
): ):
_log.info("Close: stopping audio playback thread")
start = time.perf_counter()
self.play_audio_thread.stop() self.play_audio_thread.stop()
self.play_audio_thread.wait() self.play_audio_thread.wait(1000)
_log.info(
"Close: audio thread stopped in %.2fs", time.perf_counter() - start
)
# Cleanup pygame mixer if initialized # Cleanup pygame mixer if initialized
try: try:
@@ -3206,6 +3231,7 @@ class abogen(QWidget):
pass pass
def closeEvent(self, event): def closeEvent(self, event):
_log.info("Close: window close requested (converting=%s)", self.is_converting)
if self.is_converting: if self.is_converting:
box = QMessageBox(self) box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Warning) box.setIcon(QMessageBox.Icon.Warning)
@@ -3218,16 +3244,14 @@ 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:
from abogen import shutdown _log.info("Close: user confirmed exit during conversion")
shutdown.request_shutdown()
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
self.cleanup_preview_threads() self.cleanup_preview_threads()
event.accept() event.accept()
else: else:
_log.info("Close: user cancelled exit")
event.ignore() event.ignore()
else: else:
from abogen import shutdown
shutdown.request_shutdown()
self.cleanup_conversion_thread() self.cleanup_conversion_thread()
self.cleanup_preview_threads() self.cleanup_preview_threads()
event.accept() event.accept()
+8 -1
View File
@@ -164,6 +164,9 @@ def main():
with timed_log("QApplication creation", logger=_log): with timed_log("QApplication creation", logger=_log):
app = QApplication(sys.argv) app = QApplication(sys.argv)
# Qt shutdown hook must be connected AFTER QApplication exists
shutdown.install_qt_hook()
# Set application icon using get_resource_path from utils # Set application icon using get_resource_path from utils
icon_path = get_resource_path("abogen.assets", "icon.ico") icon_path = get_resource_path("abogen.assets", "icon.ico")
if icon_path: if icon_path:
@@ -181,7 +184,11 @@ def main():
with timed_log("window show", logger=_log): with timed_log("window show", logger=_log):
ex.show() ex.show()
_log.info("App startup complete. Showing window.") _log.info("App startup complete. Showing window.")
sys.exit(app.exec()) rc = app.exec()
# Restore the default Qt message handler BEFORE interpreter shutdown.
# A Python message handler invoked during Qt teardown segfaults (SIGSEGV).
qInstallMessageHandler(None)
sys.exit(rc)
if __name__ == "__main__": if __name__ == "__main__":
+21 -3
View File
@@ -14,10 +14,14 @@ Per-conversion cleanup lives in run_conversion() finally block.
from __future__ import annotations from __future__ import annotations
import atexit import atexit
import logging
import signal import signal
import sys import sys
import time
from typing import Callable from typing import Callable
_log = logging.getLogger("abogen.shutdown")
_CLEANUP_FUNCS: list[Callable[[], None]] = [] _CLEANUP_FUNCS: list[Callable[[], None]] = []
_EXECUTED = False _EXECUTED = False
@@ -32,11 +36,17 @@ def _run_cleanups() -> None:
if _EXECUTED: if _EXECUTED:
return return
_EXECUTED = True _EXECUTED = True
_log.info("Shutdown: starting %d cleanup hook(s)", len(_CLEANUP_FUNCS))
for fn in _CLEANUP_FUNCS: for fn in _CLEANUP_FUNCS:
start = time.perf_counter()
try: try:
fn() fn()
except Exception: except Exception:
pass pass
_log.info(
"Shutdown: %s done in %.2fs", fn.__name__, time.perf_counter() - start
)
_log.info("Shutdown: all cleanups finished")
# ---- Process-level cleanup functions ---- # ---- Process-level cleanup functions ----
@@ -117,13 +127,19 @@ def register_shutdown() -> None:
except Exception: except Exception:
pass pass
# Qt hook — connect AFTER QApplication is created install_qt_hook()
def install_qt_hook() -> None:
"""Connect Qt aboutToQuit to cleanup. Must run AFTER QApplication is created."""
try: try:
from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QApplication
app = QApplication.instance() app = QApplication.instance()
if app is not None: if app is not None and not getattr(app, "_abogen_cleanup_connected", False):
app.aboutToQuit.connect(_run_cleanups) app.aboutToQuit.connect(_run_cleanups)
app._abogen_cleanup_connected = True
_log.info("Shutdown: Qt aboutToQuit hook connected")
except Exception: except Exception:
pass pass
@@ -132,13 +148,15 @@ register_shutdown._registered = False
def _on_signal(signum: int, _frame) -> None: def _on_signal(signum: int, _frame) -> None:
_log.info("Shutdown: signal %s received", signum)
_run_cleanups() _run_cleanups()
sys.exit(0) sys.exit(0)
def request_shutdown() -> None: def request_shutdown() -> None:
"""Programmatically trigger cleanup (e.g., from GUI closeEvent).""" """Programmatically trigger cleanup (e.g., from GUI closeEvent)."""
_log.info("Shutdown: cleanup requested")
_run_cleanups() _run_cleanups()
__all__ = ["register_shutdown", "request_shutdown", "register_cleanup"] __all__ = ["register_shutdown", "install_qt_hook", "request_shutdown", "register_cleanup"]
+42
View File
@@ -79,6 +79,44 @@ class SynthesisRequest:
format: AudioFormat format: AudioFormat
@dataclass(frozen=True)
class TokenTiming:
"""Per-token timing within a synthesized segment.
Attributes:
text: Token text.
whitespace: Whitespace following the token ("" if none).
start: Start time in seconds (relative to segment start).
end: End time in seconds (relative to segment start).
"""
text: str
whitespace: str = ""
start: float = 0.0
end: float = 0.0
@dataclass(frozen=True)
class AudioSegment:
"""One contiguous synthesized segment (sentence-level chunk).
Engines that split the input text (via ``split_pattern``) expose each
chunk as its own AudioSegment so hosts can report per-sentence progress
and build subtitles from per-token timings.
Attributes:
graphemes: The text this segment was synthesized from.
audio: Raw float32 PCM audio bytes for this segment.
sample_rate: Sample rate of ``audio``.
tokens: Per-token timing details, when the engine provides them.
"""
graphemes: str
audio: bytes
sample_rate: int
tokens: tuple[TokenTiming, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
class SynthesizedAudio: class SynthesizedAudio:
"""Immutable value object for synthesized audio result. """Immutable value object for synthesized audio result.
@@ -87,11 +125,15 @@ class SynthesizedAudio:
data: Raw audio bytes. data: Raw audio bytes.
format: Audio format of the result. format: Audio format of the result.
duration: Duration of the audio. duration: Duration of the audio.
segments: Per-segment details when the engine split the text into
sentence-level chunks (empty for engines that only produce a
single merged result).
""" """
data: bytes data: bytes
format: AudioFormat format: AudioFormat
duration: Duration duration: Duration
segments: tuple[AudioSegment, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
+25 -2
View File
@@ -169,15 +169,38 @@ class Pipeline:
) )
result = session.synthesize(request) result = session.synthesize(request)
audio_array = np.frombuffer(result.data, dtype=np.float32)
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass
class Token:
text: str
whitespace: str = ""
start_ts: float = 0.0
end_ts: float = 0.0
@dataclass @dataclass
class Segment: class Segment:
graphemes: str graphemes: str
audio: np.ndarray audio: np.ndarray
tokens: list[Any] = field(default_factory=list)
if result.segments:
for seg in result.segments:
audio_array = np.frombuffer(seg.audio, dtype=np.float32)
tokens = [
Token(
text=tok.text,
whitespace=tok.whitespace,
start_ts=tok.start,
end_ts=tok.end,
)
for tok in seg.tokens
]
yield Segment(graphemes=seg.graphemes, audio=audio_array, tokens=tokens)
return
audio_array = np.frombuffer(result.data, dtype=np.float32)
yield Segment(graphemes=text, audio=audio_array) yield Segment(graphemes=text, audio=audio_array)
def load_single_voice(self, voice_name: str) -> Any: def load_single_voice(self, voice_name: str) -> Any:
+5 -7
View File
@@ -16,6 +16,8 @@ from functools import lru_cache
from dotenv import load_dotenv, find_dotenv from dotenv import load_dotenv, find_dotenv
logger = logging.getLogger(__name__)
def _load_environment() -> None: def _load_environment() -> None:
explicit_path = os.environ.get("ABOGEN_ENV_FILE") explicit_path = os.environ.get("ABOGEN_ENV_FILE")
@@ -441,10 +443,6 @@ default_encoding = sys.getfilesystemencoding()
def create_process(cmd, stdin=None, text=True, capture_output=False): def create_process(cmd, stdin=None, text=True, capture_output=False):
import logging
logger = logging.getLogger(__name__)
# Configure root logger to output to console if not already configured # Configure root logger to output to console if not already configured
root = logging.getLogger() root = logging.getLogger()
if not root.handlers: if not root.handlers:
@@ -493,8 +491,8 @@ def create_process(cmd, stdin=None, text=True, capture_output=False):
} }
) )
# Print the command being executed # Log the command being executed
print(f"Executing: {cmd if isinstance(cmd, str) else ' '.join(cmd)}") logger.info(f"Executing: {cmd if isinstance(cmd, str) else ' '.join(cmd)}")
proc = subprocess.Popen(cmd, **kwargs) proc = subprocess.Popen(cmd, **kwargs)
@@ -615,7 +613,7 @@ def prevent_sleep_start():
) )
else: else:
# Non-systemd distro or systemd tools not installed: skip inhibition rather than crash # Non-systemd distro or systemd tools not installed: skip inhibition rather than crash
print( logger.warning(
"systemd-inhibit not found: skipping sleep inhibition on this Linux system." "systemd-inhibit not found: skipping sleep inhibition on this Linux system."
) )
+28 -2
View File
@@ -22,9 +22,11 @@ from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
AudioSegment,
Duration, Duration,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
TokenTiming,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -117,7 +119,9 @@ class KokoroSession:
speed = request.parameters.values.get("speed", 1.0) speed = request.parameters.values.get("speed", 1.0)
split_pattern = request.parameters.values.get("split_pattern", None) split_pattern = request.parameters.values.get("split_pattern", None)
sample_rate = _KOKORO_SAMPLE_RATE
audio_parts: list[np.ndarray] = [] audio_parts: list[np.ndarray] = []
segments: list[AudioSegment] = []
for segment in self._pipeline( for segment in self._pipeline(
request.text, request.text,
voice=voice, voice=voice,
@@ -127,7 +131,28 @@ class KokoroSession:
audio = segment.audio audio = segment.audio
if hasattr(audio, "numpy"): if hasattr(audio, "numpy"):
audio = audio.numpy() audio = audio.numpy()
audio_parts.append(np.asarray(audio, dtype="float32")) audio = np.asarray(audio, dtype="float32")
if audio.size == 0:
continue
audio_parts.append(audio)
tokens = tuple(
TokenTiming(
text=str(tok.text),
whitespace=str(tok.whitespace or ""),
start=float(tok.start_ts or 0.0),
end=float(tok.end_ts or 0.0),
)
for tok in (getattr(segment, "tokens", None) or [])
)
segments.append(
AudioSegment(
graphemes=str(getattr(segment, "graphemes", "") or ""),
audio=audio.tobytes(),
sample_rate=sample_rate,
tokens=tokens,
)
)
if not audio_parts: if not audio_parts:
return SynthesizedAudio( return SynthesizedAudio(
@@ -138,12 +163,13 @@ class KokoroSession:
combined = np.concatenate(audio_parts).astype("float32", copy=False) combined = np.concatenate(audio_parts).astype("float32", copy=False)
audio_bytes = combined.tobytes() audio_bytes = combined.tobytes()
duration_seconds = len(combined) / _KOKORO_SAMPLE_RATE duration_seconds = len(combined) / sample_rate
return SynthesizedAudio( return SynthesizedAudio(
data=audio_bytes, data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds), duration=Duration(seconds=duration_seconds),
segments=tuple(segments),
) )
except EngineError: except EngineError:
raise raise
+14 -1
View File
@@ -19,6 +19,7 @@ from abogen.tts_plugin.errors import EngineError
from abogen.tts_plugin.manifest import VoiceManifest from abogen.tts_plugin.manifest import VoiceManifest
from abogen.tts_plugin.types import ( from abogen.tts_plugin.types import (
AudioFormat, AudioFormat,
AudioSegment,
Duration, Duration,
SynthesisRequest, SynthesisRequest,
SynthesizedAudio, SynthesizedAudio,
@@ -113,6 +114,7 @@ class SuperTonicSession:
total_steps = int(total_steps) total_steps = int(total_steps)
audio_parts: list[np.ndarray] = [] audio_parts: list[np.ndarray] = []
segments: list[AudioSegment] = []
for segment in self._pipeline( for segment in self._pipeline(
request.text, request.text,
voice=voice, voice=voice,
@@ -120,7 +122,17 @@ class SuperTonicSession:
split_pattern=split_pattern, split_pattern=split_pattern,
total_steps=total_steps, total_steps=total_steps,
): ):
audio_parts.append(segment.audio) audio = np.asarray(segment.audio, dtype="float32")
if audio.size == 0:
continue
audio_parts.append(audio)
segments.append(
AudioSegment(
graphemes=str(getattr(segment, "graphemes", "") or ""),
audio=audio.tobytes(),
sample_rate=self._pipeline.sample_rate,
)
)
if not audio_parts: if not audio_parts:
return SynthesizedAudio( return SynthesizedAudio(
@@ -139,6 +151,7 @@ class SuperTonicSession:
data=audio_bytes, data=audio_bytes,
format=AudioFormat(mime="audio/wav", extension="wav"), format=AudioFormat(mime="audio/wav", extension="wav"),
duration=Duration(seconds=duration_seconds), duration=Duration(seconds=duration_seconds),
segments=tuple(segments),
) )
except EngineError: except EngineError:
raise raise
+1 -1
View File
@@ -9,7 +9,7 @@ from abogen.domain.enums import Language
from abogen.domain.split_pattern import get_split_pattern from abogen.domain.split_pattern import get_split_pattern
# --- English always returns \n --- # --- English: newline-only for Disabled/Line, punctuation-based for sentence modes ---
class TestEnglish: class TestEnglish:
def test_english_sentence(self): def test_english_sentence(self):
+19
View File
@@ -177,6 +177,19 @@ class TestAssWriter:
assert "Highlight" in content assert "Highlight" in content
assert r"{\k100}" in content assert r"{\k100}" in content
def test_highlight_mode_preserves_existing_karaoke_tags(self, tmp_path):
path = tmp_path / "test.ass"
config = SubtitleConfig(
format=SubtitleFormat.ASS,
mode=SubtitleMode.SENTENCE_HIGHLIGHT,
)
writer = AssWriter(path, config)
writer.write_entry(start=0.0, end=1.0, text=r"{\kf20}Hello {\kf20}world.")
writer.close()
content = path.read_text()
assert r"{\kf20}Hello {\kf20}world." in content
assert r"{\k100}" not in content
def test_centered_alignment(self, tmp_path): def test_centered_alignment(self, tmp_path):
path = tmp_path / "test.ass" path = tmp_path / "test.ass"
config = SubtitleConfig( config = SubtitleConfig(
@@ -243,6 +256,12 @@ class TestCreateSubtitleWriter:
with pytest.raises(ValueError): with pytest.raises(ValueError):
create_subtitle_writer(path, "xyz", "Line") create_subtitle_writer(path, "xyz", "Line")
def test_word_count_mode(self, tmp_path):
path = tmp_path / "test.srt"
writer = create_subtitle_writer(path, "srt", "5 words", max_words=5)
assert isinstance(writer, SrtWriter)
writer.close()
# =================================================================== # ===================================================================
# Context manager # Context manager