mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 19:50:59 +02:00
Compare commits
3
Commits
aaa6ac112b
...
ffac4a4da9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffac4a4da9 | ||
|
|
5432de7ac5 | ||
|
|
823f5be029 |
@@ -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).
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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+"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user