mirror of
https://github.com/denizsafak/abogen.git
synced 2026-09-20 11:40:57 +02:00
- Remove 77 unused imports across domain/application/runner files via ruff - Add # noqa: F401 to re-exports used by tests and debug_tts_runner - Fix build_tts_context: usage_counter uses 'is not None' instead of truthiness - Fix test assertions: compiled rules use 'replacement' key not 'pronunciation' - Add re-exports: _compile_pronunciation_rules, _merge_pronunciation_overrides
279 lines
8.7 KiB
Python
279 lines
8.7 KiB
Python
"""Subtitle-to-audio processing pipeline.
|
|
|
|
Converts subtitle files (SRT/ASS/VTT/timestamp text) into audio by
|
|
generating TTS for each entry and mixing into a buffer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Any, Callable, List, Optional, Tuple
|
|
|
|
import numpy as np
|
|
|
|
from abogen.domain.audio_buffer import (
|
|
fit_audio_to_duration,
|
|
ffmpeg_time_stretch,
|
|
mix_audio,
|
|
normalize_audio,
|
|
SAMPLE_RATE,
|
|
)
|
|
from abogen.domain.audio_helpers import to_float32
|
|
from abogen.domain.progress import calc_etr_str
|
|
from abogen.subtitle_utils import (
|
|
parse_ass_file,
|
|
parse_srt_file,
|
|
parse_vtt_file,
|
|
parse_timestamp_text_file,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class SubtitleEntry:
|
|
"""A single subtitle entry with timing."""
|
|
start: float
|
|
end: Optional[float]
|
|
text: str
|
|
|
|
|
|
def parse_subtitle_file(
|
|
file_path: str,
|
|
is_timestamp_text: bool = False,
|
|
) -> List[Tuple[float, Optional[float], str]]:
|
|
"""Parse a subtitle file into (start, end, text) tuples.
|
|
|
|
Args:
|
|
file_path: Path to subtitle file.
|
|
is_timestamp_text: Whether to treat as timestamp text file.
|
|
|
|
Returns:
|
|
List of (start_time, end_time, text) tuples.
|
|
"""
|
|
if is_timestamp_text:
|
|
return parse_timestamp_text_file(file_path)
|
|
|
|
import os
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
if ext == ".srt":
|
|
return parse_srt_file(file_path)
|
|
elif ext == ".vtt":
|
|
return parse_vtt_file(file_path)
|
|
else:
|
|
return parse_ass_file(file_path)
|
|
|
|
|
|
def format_time_range(
|
|
start: float,
|
|
end: Optional[float],
|
|
is_auto_end: bool = False,
|
|
) -> str:
|
|
"""Format a time range for display in logs.
|
|
|
|
Args:
|
|
start: Start time in seconds.
|
|
end: End time in seconds, or None.
|
|
is_auto_end: Whether end time is auto-detected.
|
|
|
|
Returns:
|
|
Formatted string like "00:01:23,456 - 00:01:25,789" or "00:01:23 - AUTO".
|
|
"""
|
|
def _fmt(seconds: float) -> str:
|
|
h = int(seconds // 3600)
|
|
m = int(seconds % 3600 // 60)
|
|
s = int(seconds % 60)
|
|
ms = int((seconds - int(seconds)) * 1000)
|
|
result = f"{h:02d}:{m:02d}:{s:02d}"
|
|
if ms > 0:
|
|
result += f",{ms:03d}"
|
|
return result
|
|
|
|
if is_auto_end or end is None:
|
|
return f"{_fmt(start)} - AUTO"
|
|
return f"{_fmt(start)} - {_fmt(end)}"
|
|
|
|
|
|
def speed_up_audio(
|
|
audio: np.ndarray,
|
|
speed_factor: float,
|
|
method: str = "tts",
|
|
*,
|
|
backend: Any = None,
|
|
text: str = "",
|
|
voice: Any = None,
|
|
base_speed: float = 1.0,
|
|
sample_rate: int = SAMPLE_RATE,
|
|
) -> np.ndarray:
|
|
"""Speed up audio to fit a time window.
|
|
|
|
Args:
|
|
audio: Input audio buffer.
|
|
speed_factor: Required speed multiplier.
|
|
method: "ffmpeg" for time-stretch, "tts" for regeneration.
|
|
backend: TTS backend (required if method="tts").
|
|
text: Text to regenerate (required if method="tts").
|
|
voice: Voice to use for regeneration.
|
|
base_speed: Base speed for TTS.
|
|
sample_rate: Sample rate.
|
|
|
|
Returns:
|
|
Speed-adjusted audio buffer.
|
|
"""
|
|
if speed_factor <= 1.0:
|
|
return audio
|
|
|
|
if method == "ffmpeg":
|
|
logger.info("FFmpeg time-stretch: %.2fx", speed_factor)
|
|
return ffmpeg_time_stretch(audio, speed_factor, sample_rate)
|
|
|
|
# TTS regeneration
|
|
if backend is None:
|
|
return audio
|
|
new_speed = base_speed * speed_factor
|
|
logger.info("Regenerating at %.2fx speed", new_speed)
|
|
results = [
|
|
r for r in backend(text, voice=voice, speed=new_speed, split_pattern=None)
|
|
]
|
|
chunks = [r.audio for r in results]
|
|
if not chunks:
|
|
return audio
|
|
return np.concatenate([to_float32(c) for c in chunks])
|
|
|
|
|
|
def process_subtitle_entries(
|
|
subtitles: List[Tuple[float, Optional[float], str]],
|
|
*,
|
|
backend: Any,
|
|
voice: Any,
|
|
speed: float = 1.0,
|
|
cancel_check: Callable[[], bool] = lambda: False,
|
|
log_callback: Optional[Callable[[str], None]] = None,
|
|
progress_callback: Optional[Callable[[int, str], None]] = None,
|
|
replace_newlines: bool = True,
|
|
use_gaps: bool = False,
|
|
is_timestamp_text: bool = False,
|
|
subtitle_speed_method: str = "tts",
|
|
sample_rate: int = SAMPLE_RATE,
|
|
) -> np.ndarray:
|
|
"""Process subtitle entries: generate TTS for each and mix into buffer.
|
|
|
|
This is the core domain logic for subtitle-to-audio conversion.
|
|
UI-specific concerns (signals, widgets) are handled via callbacks.
|
|
|
|
Args:
|
|
subtitles: List of (start, end, text) tuples.
|
|
backend: TTS pipeline callable.
|
|
voice: Resolved voice for TTS.
|
|
speed: TTS speed.
|
|
cancel_check: Returns True if processing should stop.
|
|
log_callback: Called with log messages.
|
|
progress_callback: Called with (percent, etr_string).
|
|
replace_newlines: Replace \\n with spaces in text.
|
|
use_gaps: Whether to use silent gaps between subtitles.
|
|
is_timestamp_text: Whether input is timestamp text.
|
|
subtitle_speed_method: "ffmpeg" or "tts" for speed adjustment.
|
|
sample_rate: Audio sample rate.
|
|
|
|
Returns:
|
|
Mixed audio buffer (float32).
|
|
"""
|
|
if not subtitles:
|
|
return np.array([], dtype="float32")
|
|
|
|
max_end = max((end for _, end, _ in subtitles if end is not None), default=0)
|
|
buffer_samples = int(max_end * sample_rate) + sample_rate
|
|
audio_buffer = np.zeros(buffer_samples, dtype="float32")
|
|
etr_start = time.time()
|
|
total = len(subtitles)
|
|
|
|
for idx, (start_time, end_time, text) in enumerate(subtitles, 1):
|
|
if cancel_check():
|
|
break
|
|
|
|
processed_text = text.replace("\n", " ") if replace_newlines else text
|
|
next_start = (
|
|
subtitles[idx][0]
|
|
if (use_gaps and idx < total)
|
|
else float("inf")
|
|
)
|
|
subtitle_duration = None if end_time is None else end_time - start_time
|
|
|
|
is_auto_end = is_timestamp_text or (use_gaps and idx == total) or end_time is None
|
|
if log_callback:
|
|
log_callback(
|
|
f"\n[{idx}/{total}] {format_time_range(start_time, end_time, is_auto_end)}: {processed_text}"
|
|
)
|
|
|
|
# Generate TTS
|
|
results = [
|
|
r for r in backend(
|
|
processed_text, voice=voice, speed=speed, split_pattern=None
|
|
)
|
|
if not cancel_check()
|
|
]
|
|
if cancel_check():
|
|
break
|
|
|
|
audio_chunks = [r.audio for r in results]
|
|
full_audio = (
|
|
np.concatenate([to_float32(a) for a in audio_chunks])
|
|
if audio_chunks
|
|
else np.zeros(int((subtitle_duration or 0) * sample_rate), dtype="float32")
|
|
)
|
|
audio_duration = len(full_audio) / sample_rate
|
|
|
|
# Timing adjustment
|
|
if is_timestamp_text:
|
|
end_time = start_time + audio_duration
|
|
subtitle_duration = audio_duration
|
|
elif use_gaps:
|
|
end_time = min(start_time + audio_duration, next_start)
|
|
subtitle_duration = end_time - start_time
|
|
elif subtitle_duration is None:
|
|
subtitle_duration = audio_duration
|
|
end_time = start_time + audio_duration
|
|
|
|
# Speed up if needed
|
|
speedup_threshold = next_start - start_time if use_gaps else subtitle_duration
|
|
if audio_duration > speedup_threshold and speedup_threshold > 0:
|
|
speed_factor = audio_duration / speedup_threshold
|
|
full_audio = speed_up_audio(
|
|
full_audio, speed_factor,
|
|
method=subtitle_speed_method,
|
|
backend=backend, text=processed_text,
|
|
voice=voice, base_speed=speed,
|
|
sample_rate=sample_rate,
|
|
)
|
|
audio_duration = len(full_audio) / sample_rate
|
|
|
|
# Adjust duration after speed change
|
|
if use_gaps:
|
|
end_time = min(start_time + audio_duration, next_start)
|
|
subtitle_duration = end_time - start_time
|
|
elif subtitle_duration is None:
|
|
subtitle_duration = audio_duration
|
|
end_time = start_time + audio_duration
|
|
|
|
# Pad or trim to subtitle duration
|
|
full_audio = fit_audio_to_duration(full_audio, subtitle_duration, sample_rate)
|
|
|
|
# Mix into buffer
|
|
start_sample = int(start_time * sample_rate)
|
|
audio_buffer = mix_audio(audio_buffer, full_audio, start_sample)
|
|
|
|
# Progress
|
|
if progress_callback:
|
|
percent = min(int(idx / total * 100), 99)
|
|
etr = calc_etr_str(time.time() - etr_start, idx, total)
|
|
progress_callback(percent, etr)
|
|
|
|
# Normalize if needed
|
|
if np.abs(audio_buffer).max() > 1.0:
|
|
logger.info("Normalizing audio (peak: %.2f)", np.abs(audio_buffer).max())
|
|
audio_buffer = normalize_audio(audio_buffer)
|
|
|
|
return audio_buffer
|