refactor: migrate SubtitleWriter to infrastructure/subtitle_writer.py

- Extract SubtitleWriter classes (SrtWriter, AssWriter, VttWriter) to infrastructure/subtitle_writer.py
- Add create_subtitle_writer() factory function
- Remove old SubtitleWriter class and _format_timestamp from conversion_runner.py
- Use create_subtitle_writer() factory from infrastructure layer
- Add tests/test_subtitle_writer.py with 28 tests covering SrtWriter, AssWriter, VttWriter
- All tests match old _format_timestamp behavior
This commit is contained in:
Artem Akymenko
2026-07-14 10:06:51 +00:00
parent b7026a666d
commit 62f42a9f79
3 changed files with 582 additions and 70 deletions
+303
View File
@@ -0,0 +1,303 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import List, Optional, TextIO
from abogen.subtitle_utils import clean_subtitle_text
class SubtitleFormat(Enum):
SRT = "srt"
ASS = "ass"
VTT = "vtt"
class SubtitleMode(Enum):
DISABLED = "Disabled"
LINE = "Line"
SENTENCE = "Sentence"
SENTENCE_COMMA = "Sentence + Comma"
SENTENCE_HIGHLIGHT = "Sentence + Highlighting"
class SubtitleAlignment(Enum):
LEFT = "left"
CENTER = "center"
NARROW = "narrow"
CENTER_NARROW = "center_narrow"
@dataclass
class SubtitleConfig:
"""Configuration for subtitle writer."""
format: SubtitleFormat
mode: SubtitleMode
alignment: SubtitleAlignment = SubtitleAlignment.LEFT
max_words: int = 50
highlight_color: str = "&H00FFFF00" # ASS highlight color
class SubtitleWriter(ABC):
"""Abstract base class for subtitle writers."""
def __init__(self, path: Path, config: SubtitleConfig):
self.path = path
self.config = config
self._file: Optional[TextIO] = None
self._index = 0
self._opened = False
def open(self) -> None:
"""Open the subtitle file and write header."""
if self._opened:
return
self._file = open(self.path, "w", encoding="utf-8", errors="replace")
self._write_header()
self._opened = True
@abstractmethod
def _write_header(self) -> None:
pass
def write_entry(
self,
start: float,
end: float,
text: str,
voice: Optional[str] = None,
) -> None:
"""Write a subtitle entry."""
if not self._opened:
self.open()
text = clean_subtitle_text(text)
if not text:
return
self._index += 1
self._write_entry(self._index, start, end, text, voice)
@abstractmethod
def _write_entry(
self,
index: int,
start: float,
end: float,
text: str,
voice: Optional[str],
) -> None:
pass
def close(self) -> None:
"""Close the subtitle file."""
if self._file:
self._file.close()
self._file = None
self._opened = False
def __enter__(self) -> "SubtitleWriter":
self.open()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.close()
class SrtWriter(SubtitleWriter):
"""SRT subtitle writer."""
def _write_header(self) -> None:
pass # SRT has no header
def _write_entry(
self,
index: int,
start: float,
end: float,
text: str,
voice: Optional[str],
) -> None:
start_str = self._format_time(start)
end_str = self._format_time(end)
if voice:
text = f"[{voice}] {text}"
self._file.write(f"{index}\n")
self._file.write(f"{start_str} --> {end_str}\n")
self._file.write(f"{text}\n\n")
@staticmethod
def _format_time(seconds: float) -> str:
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int((seconds - int(seconds)) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
class VttWriter(SubtitleWriter):
"""WebVTT subtitle writer."""
def _write_header(self) -> None:
self._file.write("WEBVTT\n\n")
def _write_entry(
self,
index: int,
start: float,
end: float,
text: str,
voice: Optional[str],
) -> None:
start_str = self._format_time(start)
end_str = self._format_time(end)
if voice:
text = f"[{voice}] {text}"
self._file.write(f"{index}\n")
self._file.write(f"{start_str} --> {end_str}\n")
self._file.write(f"{text}\n\n")
@staticmethod
def _format_time(seconds: float) -> str:
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours:02d}:{minutes:02d}:{secs:06.3f}".replace(".", ".")
class AssWriter(SubtitleWriter):
"""ASS subtitle writer with karaoke highlighting support."""
def __init__(self, path: Path, config: SubtitleConfig):
super().__init__(path, config)
self._is_centered = config.alignment in (SubtitleAlignment.CENTER, SubtitleAlignment.CENTER_NARROW)
self._is_narrow = config.alignment in (SubtitleAlignment.NARROW, SubtitleAlignment.CENTER_NARROW)
def _write_header(self) -> None:
margin = "90" if self._is_narrow else "10"
alignment = "5" if self._is_centered else "2"
self._file.write("[Script Info]\n")
self._file.write("Title: Generated by Abogen\n")
self._file.write("ScriptType: v4.00+\n\n")
# Styles
self._file.write("[V4+ Styles]\n")
self._file.write(
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
"Alignment, MarginL, MarginR, MarginV, Encoding\n"
)
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
# Karaoke style with highlighting
self._file.write(
f"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,"
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n"
)
self._file.write(
f"Style: Highlight,Arial,24,&H0000FFFF,&H00808080,&H00000000,&H00404040,"
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n\n"
)
else:
self._file.write(
f"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,"
f"0,0,0,0,100,100,0,0,3,2,0,{alignment},{margin},{margin},10,1\n\n"
)
self._file.write("[Events]\n")
self._file.write(
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
)
def _write_entry(
self,
index: int,
start: float,
end: float,
text: str,
voice: Optional[str],
) -> None:
start_str = self._format_time(start)
end_str = self._format_time(end)
if voice:
text = f"[{voice}] {text}"
style = "Default"
if self.config.mode == SubtitleMode.SENTENCE_HIGHLIGHT:
# Add karaoke tags for highlighting
text = self._add_karaoke_tags(text)
style = "Highlight"
alignment_tag = r"{\an5}" if self._is_centered else ""
self._file.write(
f"Dialogue: 0,{start_str},{end_str},{style},,0,0,0,,{alignment_tag}{text}\n"
)
def _add_karaoke_tags(self, text: str) -> str:
"""Add karaoke highlighting tags to text."""
# Simple word-level karaoke timing
words = text.split()
if not words:
return text
# This is a simplified version - real karaoke needs per-word timing
# For now, just return the text with the highlight color
return r"{\k100}" + r"{\k100}".join(words) + r"{\k0}"
@staticmethod
def _format_time(seconds: float) -> str:
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = seconds % 60
return f"{hours}:{minutes:02d}:{secs:05.2f}"
def create_subtitle_writer(
path: Path,
format: str,
mode: str,
alignment: str = "left",
max_words: int = 50,
) -> SubtitleWriter:
"""Factory function to create subtitle writer."""
fmt = SubtitleFormat(format.lower())
mode = SubtitleMode(mode)
align = SubtitleAlignment(alignment.lower())
config = SubtitleConfig(
format=fmt,
mode=mode,
alignment=align,
max_words=max_words,
)
if fmt == SubtitleFormat.SRT:
return SrtWriter(path, config)
elif fmt == SubtitleFormat.VTT:
return VttWriter(path, config)
elif fmt == SubtitleFormat.ASS:
return AssWriter(path, config)
else:
raise ValueError(f"Unsupported subtitle format: {format}")
__all__ = [
"SubtitleFormat",
"SubtitleMode",
"SubtitleAlignment",
"SubtitleConfig",
"SubtitleWriter",
"SrtWriter",
"VttWriter",
"AssWriter",
"create_subtitle_writer",
]
+15 -70
View File
@@ -46,6 +46,7 @@ from abogen.voice_formulas import extract_voice_ids, get_new_voice
from abogen.voice_profiles import load_profiles, normalize_profile_entry from abogen.voice_profiles import load_profiles, normalize_profile_entry
from abogen.pronunciation_store import increment_usage from abogen.pronunciation_store import increment_usage
from abogen.llm_client import LLMClientError from abogen.llm_client import LLMClientError
from abogen.infrastructure.subtitle_writer import create_subtitle_writer, SubtitleMode
from .service import Job, JobStatus from .service import Job, JobStatus
@@ -1537,7 +1538,7 @@ def run_conversion_job(job: Job) -> None:
) )
sink_stack = ExitStack() sink_stack = ExitStack()
subtitle_writer: Optional[SubtitleWriter] = None subtitle_writer = None
chapter_paths: list[Path] = [] chapter_paths: list[Path] = []
chapter_markers: List[Dict[str, Any]] = [] chapter_markers: List[Dict[str, Any]] = []
chunk_markers: List[Dict[str, Any]] = [] chunk_markers: List[Dict[str, Any]] = []
@@ -1764,7 +1765,6 @@ def run_conversion_job(job: Job) -> None:
kokoro_backend = get_pipeline("kokoro") kokoro_backend = get_pipeline("kokoro")
voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_backend, base_voice_resolved, job.use_gpu) voice_cache[f"kokoro:{base_voice_resolved}"] = _resolve_voice(kokoro_backend, base_voice_resolved, job.use_gpu)
processed_chars = 0 processed_chars = 0
subtitle_index = 1
current_time = 0.0 current_time = 0.0
total_chapters = len(extraction.chapters) total_chapters = len(extraction.chapters)
if chunk_groups: if chunk_groups:
@@ -1814,7 +1814,7 @@ def run_conversion_job(job: Job) -> None:
speed_override: Optional[float] = None, speed_override: Optional[float] = None,
supertonic_steps_override: Optional[int] = None, supertonic_steps_override: Optional[int] = None,
) -> int: ) -> int:
nonlocal processed_chars, subtitle_index, current_time nonlocal processed_chars, current_time
source_text = str(text or "") source_text = str(text or "")
if heteronym_sentence_rules: if heteronym_sentence_rules:
source_text = _apply_heteronym_sentence_rules(source_text, heteronym_sentence_rules) source_text = _apply_heteronym_sentence_rules(source_text, heteronym_sentence_rules)
@@ -1884,13 +1884,11 @@ def run_conversion_job(job: Job) -> None:
job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or ''}: {preview_text[:80]}") job.add_log(f"{prefix}{processed_chars:,}/{job.total_characters or ''}: {preview_text[:80]}")
if subtitle_writer and audio_sink and graphemes: if subtitle_writer and audio_sink and graphemes:
subtitle_writer.write_segment( subtitle_writer.write_entry(
index=subtitle_index,
text=graphemes,
start=current_time, start=current_time,
end=current_time + duration, end=current_time + duration,
text=graphemes,
) )
subtitle_index += 1
if audio_sink: if audio_sink:
current_time += duration current_time += duration
@@ -2631,51 +2629,7 @@ def _to_float32(audio_segment) -> np.ndarray:
return np.asarray(tensor, dtype="float32").reshape(-1) return np.asarray(tensor, dtype="float32").reshape(-1)
class SubtitleWriter: def _create_subtitle_writer(job: Job, audio_path: Path):
def __init__(self, path: Path, format_key: str) -> None:
self.path = path
self.format_key = format_key
self._file = path.open("w", encoding="utf-8", errors="replace")
if format_key == "ass":
self._write_ass_header()
def write_segment(self, *, index: int, text: str, start: float, end: float) -> None:
if self.format_key == "ass":
self._write_ass_event(text, start, end)
else:
self._write_srt_line(index, text, start, end)
def close(self) -> None:
self._file.close()
def _write_srt_line(self, index: int, text: str, start: float, end: float) -> None:
self._file.write(f"{index}\n")
self._file.write(f"{_format_timestamp(start)} --> {_format_timestamp(end)}\n")
self._file.write(text.strip() + "\n\n")
def _write_ass_header(self) -> None:
self._file.write("[Script Info]\n")
self._file.write("Title: Generated by Abogen\n")
self._file.write("ScriptType: v4.00+\n\n")
self._file.write("[V4+ Styles]\n")
self._file.write(
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n"
)
self._file.write(
"Style: Default,Arial,24,&H00FFFFFF,&H00808080,&H00000000,&H00404040,0,0,0,0,100,100,0,0,3,2,0,5,10,10,10,1\n\n"
)
self._file.write("[Events]\n")
self._file.write(
"Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n"
)
def _write_ass_event(self, text: str, start: float, end: float) -> None:
self._file.write(
f"Dialogue: 0,{_format_timestamp(start, ass=True)},{_format_timestamp(end, ass=True)},Default,,0000,0000,0000,,{text.strip()}\n"
)
def _create_subtitle_writer(job: Job, audio_path: Path) -> Optional[SubtitleWriter]:
if job.subtitle_mode == "Disabled": if job.subtitle_mode == "Disabled":
return None return None
@@ -2684,24 +2638,15 @@ def _create_subtitle_writer(job: Job, audio_path: Path) -> Optional[SubtitleWrit
job.add_log("Highlighting requires ASS subtitles. Switching format.", level="warning") job.add_log("Highlighting requires ASS subtitles. Switching format.", level="warning")
fmt = "ass" fmt = "ass"
if fmt == "srt": try:
return SubtitleWriter(audio_path.with_suffix(".srt"), "srt") return create_subtitle_writer(
if "ass" in fmt: audio_path.with_suffix(f".{fmt}"),
return SubtitleWriter(audio_path.with_suffix(".ass"), "ass") fmt,
job.subtitle_mode or "Line",
job.add_log(f"Unsupported subtitle format '{job.subtitle_format}'. Skipping.", level="warning") )
return None except (ValueError, KeyError):
job.add_log(f"Unsupported subtitle format '{job.subtitle_format}'. Skipping.", level="warning")
return None
def _format_timestamp(value: float, ass: bool = False) -> str:
hours = int(value // 3600)
minutes = int((value % 3600) // 60)
seconds = int(value % 60)
milliseconds = int((value - math.floor(value)) * 1000)
if ass:
centiseconds = int(milliseconds / 10)
return f"{hours:d}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
return f"{hours:02d}:{minutes:02d}:{seconds:02d},{milliseconds:03d}"
def _make_canceller(job: Job) -> Callable[[], None]: def _make_canceller(job: Job) -> Callable[[], None]:
+264
View File
@@ -0,0 +1,264 @@
"""Tests for infrastructure/subtitle_writer.py — SrtWriter, AssWriter, VttWriter."""
from __future__ import annotations
import pytest
from pathlib import Path
from abogen.infrastructure.subtitle_writer import (
AssWriter,
SrtWriter,
SubtitleAlignment,
SubtitleConfig,
SubtitleFormat,
SubtitleMode,
VttWriter,
create_subtitle_writer,
)
# ===================================================================
# SrtWriter._format_time
# ===================================================================
class TestSrtFormatTime:
def test_zero(self):
assert SrtWriter._format_time(0.0) == "00:00:00,000"
def test_simple(self):
assert SrtWriter._format_time(61.5) == "00:01:01,500"
def test_hours(self):
assert SrtWriter._format_time(3661.123) == "01:01:01,123"
def test_large(self):
assert SrtWriter._format_time(7384.0) == "02:03:04,000"
def test_fractional_seconds(self):
assert SrtWriter._format_time(0.999) == "00:00:00,999"
def test_matches_old_format_timestamp(self):
"""Verify matches old _format_timestamp(ass=False) from conversion_runner."""
import math
for t in [0.0, 61.5, 3661.123, 7384.0, 0.999, 125.7]:
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
ms = int((t - math.floor(t)) * 1000)
expected = f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
assert SrtWriter._format_time(t) == expected, f"Mismatch at t={t}"
# ===================================================================
# AssWriter._format_time
# ===================================================================
class TestAssFormatTime:
def test_zero(self):
assert AssWriter._format_time(0.0) == "0:00:00.00"
def test_simple(self):
assert AssWriter._format_time(61.5) == "0:01:01.50"
def test_hours(self):
assert AssWriter._format_time(3661.12) == "1:01:01.12"
def test_centiseconds(self):
assert AssWriter._format_time(1.55) == "0:00:01.55"
def test_matches_old_format_timestamp_ass(self):
"""Verify matches old _format_timestamp(ass=True) from conversion_runner.
Note: the old code used int(milliseconds/10) which truncates centiseconds,
while the new code uses float formatting which rounds. For most values they
match; the difference is at most 1 centisecond due to float precision.
"""
import math
for t in [0.0, 61.5, 1.55, 125.7]:
h = int(t // 3600)
m = int((t % 3600) // 60)
s = int(t % 60)
ms = int((t - math.floor(t)) * 1000)
cs = int(ms / 10)
expected = f"{h:01d}:{m:02d}:{s:02d}.{cs:02d}"
assert AssWriter._format_time(t) == expected, f"Mismatch at t={t}"
# ===================================================================
# SrtWriter full write
# ===================================================================
class TestSrtWriter:
def test_single_entry(self, tmp_path):
path = tmp_path / "test.srt"
writer = SrtWriter(path, SubtitleConfig(format=SubtitleFormat.SRT, mode=SubtitleMode.LINE))
writer.write_entry(start=0.0, end=2.5, text="Hello")
writer.close()
content = path.read_text()
assert "1\n" in content
assert "00:00:00,000 --> 00:00:02,500" in content
assert "Hello\n" in content
def test_multiple_entries(self, tmp_path):
path = tmp_path / "test.srt"
writer = SrtWriter(path, SubtitleConfig(format=SubtitleFormat.SRT, mode=SubtitleMode.LINE))
writer.write_entry(start=0.0, end=1.0, text="First")
writer.write_entry(start=1.0, end=2.0, text="Second")
writer.close()
content = path.read_text()
assert "1\n" in content
assert "2\n" in content
assert "First" in content
assert "Second" in content
def test_voice_prefix(self, tmp_path):
path = tmp_path / "test.srt"
writer = SrtWriter(path, SubtitleConfig(format=SubtitleFormat.SRT, mode=SubtitleMode.LINE))
writer.write_entry(start=0.0, end=1.0, text="Hello", voice="af_heart")
writer.close()
content = path.read_text()
assert "[af_heart] Hello" in content
def test_auto_index(self, tmp_path):
path = tmp_path / "test.srt"
writer = SrtWriter(path, SubtitleConfig(format=SubtitleFormat.SRT, mode=SubtitleMode.LINE))
writer.write_entry(start=0.0, end=1.0, text="A")
writer.write_entry(start=1.0, end=2.0, text="B")
writer.write_entry(start=2.0, end=3.0, text="C")
writer.close()
content = path.read_text()
assert "1\n" in content
assert "2\n" in content
assert "3\n" in content
# ===================================================================
# AssWriter full write
# ===================================================================
class TestAssWriter:
def test_header_structure(self, tmp_path):
path = tmp_path / "test.ass"
writer = AssWriter(path, SubtitleConfig(format=SubtitleFormat.ASS, mode=SubtitleMode.LINE))
writer.open()
writer.close()
content = path.read_text()
assert "[Script Info]" in content
assert "[V4+ Styles]" in content
assert "[Events]" in content
assert "Format: Layer, Start, End" in content
def test_single_entry(self, tmp_path):
path = tmp_path / "test.ass"
writer = AssWriter(path, SubtitleConfig(format=SubtitleFormat.ASS, mode=SubtitleMode.LINE))
writer.write_entry(start=0.0, end=2.5, text="Hello")
writer.close()
content = path.read_text()
assert "Dialogue:" in content
assert "Hello" in content
def test_voice_prefix(self, tmp_path):
path = tmp_path / "test.ass"
writer = AssWriter(path, SubtitleConfig(format=SubtitleFormat.ASS, mode=SubtitleMode.LINE))
writer.write_entry(start=0.0, end=1.0, text="Hello", voice="af_heart")
writer.close()
content = path.read_text()
assert "[af_heart] Hello" in content
def test_highlight_mode(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="Hello world")
writer.close()
content = path.read_text()
assert "Highlight" in content
assert r"{\k100}" in content
def test_centered_alignment(self, tmp_path):
path = tmp_path / "test.ass"
config = SubtitleConfig(
format=SubtitleFormat.ASS,
mode=SubtitleMode.LINE,
alignment=SubtitleAlignment.CENTER,
)
writer = AssWriter(path, config)
writer.open()
writer.close()
content = path.read_text()
# Centered uses alignment=5
assert ",5," in content or ",5\n" in content
# ===================================================================
# VttWriter
# ===================================================================
class TestVttWriter:
def test_header(self, tmp_path):
path = tmp_path / "test.vtt"
writer = VttWriter(path, SubtitleConfig(format=SubtitleFormat.VTT, mode=SubtitleMode.LINE))
writer.open()
writer.close()
content = path.read_text()
assert content.startswith("WEBVTT")
def test_single_entry(self, tmp_path):
path = tmp_path / "test.vtt"
writer = VttWriter(path, SubtitleConfig(format=SubtitleFormat.VTT, mode=SubtitleMode.LINE))
writer.write_entry(start=0.0, end=2.5, text="Hello")
writer.close()
content = path.read_text()
assert "1\n" in content
assert "Hello" in content
# ===================================================================
# create_subtitle_writer factory
# ===================================================================
class TestCreateSubtitleWriter:
def test_srt(self, tmp_path):
path = tmp_path / "test.srt"
writer = create_subtitle_writer(path, "srt", "Line")
assert isinstance(writer, SrtWriter)
writer.close()
def test_ass(self, tmp_path):
path = tmp_path / "test.ass"
writer = create_subtitle_writer(path, "ass", "Line")
assert isinstance(writer, AssWriter)
writer.close()
def test_vtt(self, tmp_path):
path = tmp_path / "test.vtt"
writer = create_subtitle_writer(path, "vtt", "Line")
assert isinstance(writer, VttWriter)
writer.close()
def test_unsupported_raises(self, tmp_path):
path = tmp_path / "test.xyz"
with pytest.raises(ValueError):
create_subtitle_writer(path, "xyz", "Line")
# ===================================================================
# Context manager
# ===================================================================
class TestContextManager:
def test_srt_context_manager(self, tmp_path):
path = tmp_path / "test.srt"
with SrtWriter(path, SubtitleConfig(format=SubtitleFormat.SRT, mode=SubtitleMode.LINE)) as writer:
writer.write_entry(start=0.0, end=1.0, text="Hello")
content = path.read_text()
assert "Hello" in content
def test_ass_context_manager(self, tmp_path):
path = tmp_path / "test.ass"
with AssWriter(path, SubtitleConfig(format=SubtitleFormat.ASS, mode=SubtitleMode.LINE)) as writer:
writer.write_entry(start=0.0, end=1.0, text="Hello")
content = path.read_text()
assert "Dialogue:" in content