refactor: extract _process_subtitle_file domain logic to shared modules

- domain/subtitle_processor.py: parse_subtitle_file, format_time_range,
  speed_up_audio, fit_audio_to_duration (moved from audio_buffer),
  process_subtitle_entries (core TTS loop with cancel/log/progress callbacks)
- domain/audio_buffer.py: add fit_audio_to_duration, ffmpeg_time_stretch
- domain/output_paths.py: add resolve_unique_path (collision-safe filename)
- pyqt/conversion.py: _process_subtitle_file reduced from ~350 to ~100 lines
  by delegating to domain functions; removed 4 unused subtitle parser imports
- +33 tests (8 resolve_unique_path, 14 subtitle_processor, 8 audio_buffer,
  3 format_time_range)
- 1153 tests pass
This commit is contained in:
Artem Akymenko
2026-07-19 09:34:17 +00:00
parent 380cdee0cb
commit 0e216f3786
7 changed files with 698 additions and 261 deletions
+67
View File
@@ -170,3 +170,70 @@ def samples_for_duration(duration_seconds: float, sample_rate: int = SAMPLE_RATE
if duration_seconds <= 0:
return 0
return int(round(duration_seconds * sample_rate))
def fit_audio_to_duration(
audio: np.ndarray,
target_duration: float,
sample_rate: int = SAMPLE_RATE,
) -> np.ndarray:
"""Pad or trim audio to match target duration.
Args:
audio: Input audio buffer.
target_duration: Desired duration in seconds.
sample_rate: Sample rate in Hz.
Returns:
Audio buffer of exact length target_duration * sample_rate.
"""
target_samples = int(target_duration * sample_rate)
if len(audio) < target_samples:
padding = np.zeros(target_samples - len(audio), dtype="float32")
return np.concatenate([audio, padding])
return audio[:target_samples]
def ffmpeg_time_stretch(
audio: np.ndarray,
speed_factor: float,
sample_rate: int = SAMPLE_RATE,
) -> np.ndarray:
"""Time-stretch audio using FFmpeg's atempo filter.
Args:
audio: Input audio buffer (float32).
speed_factor: Speed multiplier (>1.0 = faster).
sample_rate: Sample rate in Hz.
Returns:
Time-stretched audio buffer.
"""
import math
import subprocess
import static_ffmpeg
if speed_factor <= 1.0 or audio.size == 0:
return audio
static_ffmpeg.add_paths()
num_stages = max(1, int(math.ceil(math.log(speed_factor) / math.log(2.0))))
tempo = speed_factor ** (1.0 / num_stages)
filter_str = ",".join([f"atempo={tempo:.6f}"] * num_stages)
proc = subprocess.Popen(
[
"ffmpeg", "-y",
"-f", "f32le", "-ar", str(sample_rate), "-ac", "1",
"-i", "pipe:0",
"-filter:a", filter_str,
"-f", "f32le", "-ar", str(sample_rate), "-ac", "1",
"pipe:1",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, _ = proc.communicate(input=audio.tobytes())
return np.frombuffer(out, dtype="float32")
+39
View File
@@ -6,12 +6,14 @@ and computing project folder layouts.
from __future__ import annotations
import os
import platform
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple
from abogen.subtitle_utils import sanitize_name_for_os
from abogen.text_extractor import ExtractedChapter
@@ -139,3 +141,40 @@ def resolve_project_layout(
return project_root, audio_dir, subtitle_dir, metadata_dir
return project_root, project_root, project_root, None
def resolve_unique_path(
parent_dir: str,
base_name: str,
extension: str,
allowed_extensions: Optional[set] = None,
) -> str:
"""Find a unique file path by appending _2, _3, etc. on collision.
Args:
parent_dir: Directory to check for collisions.
base_name: Base filename (without extension).
extension: File extension (without dot).
allowed_extensions: Set of extensions to check against.
If None, checks any existing file/dir with same name.
Returns:
Full path without extension (e.g. "/path/to/name_2").
"""
sanitized = sanitize_name_for_os(base_name, is_folder=True)
counter = 1
while True:
suffix = f"_{counter}" if counter > 1 else ""
candidate = os.path.join(parent_dir, f"{sanitized}{suffix}")
if allowed_extensions is not None:
file_parts = (os.path.splitext(f) for f in os.listdir(parent_dir))
clash = any(
name == f"{sanitized}{suffix}"
and ext[1:].lower() in allowed_extensions
for name, ext in file_parts
)
else:
clash = os.path.exists(candidate)
if not clash:
return candidate
counter += 1
+279
View File
@@ -0,0 +1,279 @@
"""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 (
create_silence,
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
+24 -251
View File
@@ -23,11 +23,16 @@ from abogen.constants import (
)
from abogen.infrastructure.subtitle_writer import create_subtitle_writer
from abogen.domain.split_pattern import get_split_pattern
from abogen.domain.subtitle_processor import (
parse_subtitle_file,
process_subtitle_entries,
)
from abogen.domain.output_paths import (
resolve_output_directory,
build_output_path,
sanitize_output_stem,
sanitize_filename_for_chapter,
resolve_unique_path,
)
from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32
from abogen.domain.audio_sink import AudioSink, open_audio_sink
@@ -79,11 +84,7 @@ _USER_RESPONSE_TIMEOUT = (
from abogen.subtitle_utils import (
clean_text,
parse_srt_file,
parse_vtt_file,
detect_timestamps_in_text,
parse_timestamp_text_file,
parse_ass_file,
get_sample_voice_text,
sanitize_name_for_os,
split_text_by_voice_markers
@@ -1178,16 +1179,7 @@ class ConversionThread(QThread):
"""Process subtitle files with precise timing and generate output subtitles."""
try:
# Parse subtitle file
if is_timestamp_text:
subtitles = parse_timestamp_text_file(self.file_name)
else:
file_ext = os.path.splitext(self.file_name)[1].lower()
if file_ext == ".srt":
subtitles = parse_srt_file(self.file_name)
elif file_ext == ".vtt":
subtitles = parse_vtt_file(self.file_name)
else:
subtitles = parse_ass_file(self.file_name)
subtitles = parse_subtitle_file(self.file_name, is_timestamp_text)
if not subtitles:
self.log_updated.emit(("No valid subtitle entries found.", "red"))
@@ -1202,7 +1194,6 @@ class ConversionThread(QThread):
# Setup output paths
base_name = os.path.splitext(os.path.basename(base_path))[0]
sanitized_base_name = sanitize_name_for_os(base_name, is_folder=True)
parent_dir = (
user_desktop_dir()
if self.save_option == "Save to Desktop"
@@ -1219,24 +1210,11 @@ class ConversionThread(QThread):
)
return
# Find unique filename
counter = 1
allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS)
while True:
suffix = f"_{counter}" if counter > 1 else ""
# Use generator expression to avoid processing all files upfront
file_parts = (os.path.splitext(f) for f in os.listdir(parent_dir))
if not any(
name == f"{sanitized_base_name}{suffix}"
and ext[1:].lower() in allowed_exts
for name, ext in file_parts
):
break
counter += 1
base_filepath_no_ext = os.path.join(
parent_dir, f"{sanitized_base_name}{suffix}"
sanitized_base_path = resolve_unique_path(
parent_dir, base_name, self.output_format, allowed_exts
)
base_filepath_no_ext = sanitized_base_path
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
rate = 24000
@@ -1265,81 +1243,20 @@ class ConversionThread(QThread):
# Load voice
loaded_voice = resolve_voice(self.voice, tts, self.use_gpu)
# Calculate initial audio buffer size from timed subtitles only
max_end_time = max(
(end for _, end, _ in subtitles if end is not None), default=0
)
buffer_samples = int(max_end_time * rate) + rate
audio_buffer = np.zeros(buffer_samples, dtype="float32")
# Process each subtitle and mix into buffer
self.etr_start_time = time.time()
for idx, (start_time, end_time, text) in enumerate(subtitles, 1):
if self.cancel_requested:
if subtitle_writer:
subtitle_writer.close()
self.conversion_finished.emit("Cancelled", None)
return
# Process text and timing
replace_nl = getattr(self, "replace_single_newlines", True)
processed_text = text.replace("\n", " ") if replace_nl else text
use_gaps = getattr(self, "use_silent_gaps", False)
next_start = (
subtitles[idx][0]
if (use_gaps and idx < len(subtitles))
else float("inf")
)
subtitle_duration = None if end_time is None else end_time - start_time
h1, m1, s1 = (
int(start_time // 3600),
int(start_time % 3600 // 60),
int(start_time % 60),
)
ms1 = int((start_time - int(start_time)) * 1000)
is_last = (
is_timestamp_text
or (use_gaps and idx == len(subtitles))
or end_time is None
)
if is_last:
time_str = (
f"{h1:02d}:{m1:02d}:{s1:02d}"
+ (f",{ms1:03d}" if ms1 > 0 else "")
+ " - AUTO"
)
else:
h2, m2, s2 = (
int(end_time // 3600),
int(end_time % 3600 // 60),
int(end_time % 60),
)
ms2 = int((end_time - int(end_time)) * 1000)
time_str = (
f"{h1:02d}:{m1:02d}:{s1:02d}"
+ (f",{ms1:03d}" if ms1 > 0 else "")
+ " - "
+ f"{h2:02d}:{m2:02d}:{s2:02d}"
+ (f",{ms2:03d}" if ms2 > 0 else "")
)
self.log_updated.emit(
f"\n[{idx}/{len(subtitles)}] {time_str}: {processed_text}"
)
# Generate TTS audio
tts_results = [
r
for r in self.backend(
processed_text,
# Process all subtitles via domain
audio_buffer = process_subtitle_entries(
subtitles,
backend=self.backend,
voice=loaded_voice,
speed=self.speed,
split_pattern=None,
cancel_check=lambda: self.cancel_requested,
log_callback=lambda msg: self.log_updated.emit((msg, "grey")),
progress_callback=self.progress_updated.emit,
replace_newlines=getattr(self, "replace_single_newlines", True),
use_gaps=getattr(self, "use_silent_gaps", False),
is_timestamp_text=is_timestamp_text,
subtitle_speed_method=getattr(self, "subtitle_speed_method", "tts"),
)
if not self.cancel_requested
]
audio_chunks = [r.audio for r in tts_results]
if self.cancel_requested:
if subtitle_writer:
@@ -1347,160 +1264,16 @@ class ConversionThread(QThread):
self.conversion_finished.emit("Cancelled", None)
return
# Concatenate audio and determine duration
full_audio = (
np.concatenate(
[to_float32(a) for a in audio_chunks]
)
if audio_chunks
else np.zeros(
int((subtitle_duration or 0) * rate), dtype="float32"
)
)
audio_duration = len(full_audio) / rate
# Use actual audio length for timing
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:
speed_factor = audio_duration / speedup_threshold
if getattr(self, "subtitle_speed_method", "tts") == "ffmpeg":
# FFmpeg time-stretch (faster processing)
self.log_updated.emit(
(f" -> FFmpeg time-stretch: {speed_factor:.2f}x", "grey")
)
static_ffmpeg.add_paths()
num_stages = max(
1,
int(
np.ceil(
np.log(speed_factor) / np.log(2.0)
)
),
)
tempo = speed_factor ** (1.0 / num_stages)
filter_str = ",".join([f"atempo={tempo:.6f}"] * num_stages)
speed_proc = subprocess.Popen(
[
"ffmpeg",
"-y",
"-f",
"f32le",
"-ar",
str(rate),
"-ac",
"1",
"-i",
"pipe:0",
"-filter:a",
filter_str,
"-f",
"f32le",
"-ar",
str(rate),
"-ac",
"1",
"pipe:1",
],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
full_audio = np.frombuffer(
speed_proc.communicate(input=full_audio.tobytes())[0],
dtype="float32",
)
audio_duration = len(full_audio) / rate
else:
# TTS regeneration (better quality)
new_speed = self.speed * speed_factor
self.log_updated.emit(
(f" -> Regenerating at {new_speed:.2f}x speed", "grey")
)
tts_results = [
r
for r in self.backend(
processed_text,
voice=loaded_voice,
speed=new_speed,
split_pattern=None,
)
if not self.cancel_requested
]
audio_chunks = [r.audio for r in tts_results]
full_audio = (
np.concatenate(
[to_float32(a) for a in audio_chunks]
)
if audio_chunks
else np.zeros(
int(subtitle_duration * rate), dtype="float32"
)
)
audio_duration = len(full_audio) / rate
# Adjust duration after potential speed changes
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
target_samples = int(subtitle_duration * rate)
if len(full_audio) < target_samples:
padding_duration = (target_samples - len(full_audio)) / rate
full_audio = np.concatenate([full_audio, create_silence(padding_duration)])
elif len(full_audio) > target_samples:
full_audio = full_audio[:target_samples]
# Mix audio into buffer at the correct position (handles overlaps)
start_sample = int(start_time * rate)
audio_buffer = mix_audio(audio_buffer, full_audio, start_sample)
# Write subtitle
if subtitle_writer:
# Write subtitle entries (post-loop)
for start_time, end_time, text in subtitles:
processed_text = text.replace("\n", " ") if getattr(self, "replace_single_newlines", True) else text
display_text = (
processed_text
if "ass" in subtitle_format or replace_nl
if "ass" in subtitle_format
else processed_text.replace("\n", "\\N")
)
subtitle_writer.write_entry(start_time, end_time, display_text)
# Update progress
percent = min(int(idx / len(subtitles) * 100), 99)
etr_str = calc_etr_str(
time.time() - self.etr_start_time,
idx,
len(subtitles),
)
self.progress_updated.emit(percent, etr_str)
# Normalize audio buffer to prevent clipping from mixed overlaps
if np.abs(audio_buffer).max() > 1.0:
self.log_updated.emit(
f"\n -> Normalizing audio (peak: {np.abs(audio_buffer).max():.2f})"
)
audio_buffer = normalize_audio(audio_buffer)
# Write the complete audio buffer
self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey"))
if merged_sink:
+59
View File
@@ -0,0 +1,59 @@
"""Tests for domain/audio_buffer.py — fit_audio_to_duration, ffmpeg_time_stretch."""
import numpy as np
import pytest
from abogen.domain.audio_buffer import fit_audio_to_duration, ffmpeg_time_stretch, SAMPLE_RATE
class TestFitAudioToDuration:
def test_exact_length(self):
audio = np.ones(24000, dtype="float32")
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
assert len(result) == 24000
def test_shorter_pads_with_zeros(self):
audio = np.ones(12000, dtype="float32")
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
assert len(result) == 24000
assert result[0] == 1.0
assert result[12000] == 0.0
def test_longer_trims(self):
audio = np.ones(48000, dtype="float32")
result = fit_audio_to_duration(audio, 1.0, SAMPLE_RATE)
assert len(result) == 24000
assert result[-1] == 1.0
def test_empty_input(self):
result = fit_audio_to_duration(np.array([], dtype="float32"), 0.5, SAMPLE_RATE)
assert len(result) == 12000
assert np.all(result == 0.0)
def test_output_dtype(self):
audio = np.ones(100, dtype="float32")
result = fit_audio_to_duration(audio, 0.5, SAMPLE_RATE)
assert result.dtype == np.float32
class TestFfmpegTimeStretch:
def test_no_stretch_below_threshold(self):
audio = np.ones(24000, dtype="float32")
result = ffmpeg_time_stretch(audio, 0.8, SAMPLE_RATE)
np.testing.assert_array_equal(result, audio)
def test_no_stretch_at_exactly_one(self):
audio = np.ones(24000, dtype="float32")
result = ffmpeg_time_stretch(audio, 1.0, SAMPLE_RATE)
np.testing.assert_array_equal(result, audio)
def test_empty_audio(self):
result = ffmpeg_time_stretch(np.array([], dtype="float32"), 2.0, SAMPLE_RATE)
assert len(result) == 0
def test_stretch_reduces_duration(self):
audio = np.random.randn(48000).astype("float32")
result = ffmpeg_time_stretch(audio, 2.0, SAMPLE_RATE)
assert len(result) < len(audio)
assert len(result) > 0
assert result.dtype == np.float32
+51
View File
@@ -0,0 +1,51 @@
"""Tests for domain/output_paths.py — resolve_unique_path."""
import os
import tempfile
from abogen.domain.output_paths import resolve_unique_path
class TestResolveUniquePath:
def test_no_collision(self, tmp_path):
result = resolve_unique_path(str(tmp_path), "chapter", "srt")
assert result == os.path.join(str(tmp_path), "chapter")
assert not os.path.exists(result)
def test_collision_appends_counter(self, tmp_path):
(tmp_path / "chapter.srt").touch()
result = resolve_unique_path(str(tmp_path), "chapter", "srt", {"srt"})
assert result == os.path.join(str(tmp_path), "chapter_2")
def test_multiple_collisions(self, tmp_path):
(tmp_path / "chapter.srt").touch()
(tmp_path / "chapter_2.srt").touch()
(tmp_path / "chapter_3.srt").touch()
result = resolve_unique_path(str(tmp_path), "chapter", "srt", {"srt"})
assert result == os.path.join(str(tmp_path), "chapter_4")
def test_no_allowed_extensions_skips_files(self, tmp_path):
(tmp_path / "chapter.txt").touch()
result = resolve_unique_path(str(tmp_path), "chapter", "srt")
assert result == os.path.join(str(tmp_path), "chapter")
def test_sanitizes_name(self, tmp_path):
# On Windows, ":" is illegal; on Linux it's allowed.
# Just verify the function doesn't crash and returns a valid path.
result = resolve_unique_path(str(tmp_path), "My Chapter: Part 1", "srt")
assert os.path.dirname(result) == str(tmp_path)
def test_directory_collision(self, tmp_path):
(tmp_path / "chapter").mkdir()
result = resolve_unique_path(str(tmp_path), "chapter", "srt")
assert result == os.path.join(str(tmp_path), "chapter_2")
def test_case_insensitive_extension(self, tmp_path):
(tmp_path / "chapter.SRT").touch()
result = resolve_unique_path(str(tmp_path), "chapter", "srt", {"srt"})
assert result == os.path.join(str(tmp_path), "chapter_2")
def test_unrelated_extensions_no_collision(self, tmp_path):
(tmp_path / "chapter.mp3").touch()
result = resolve_unique_path(str(tmp_path), "chapter", "srt", {"srt"})
assert result == os.path.join(str(tmp_path), "chapter")
+169
View File
@@ -0,0 +1,169 @@
"""Tests for domain/subtitle_processor.py — parse_subtitle_file, format_time_range, process_subtitle_entries."""
import os
import tempfile
from dataclasses import dataclass
from typing import Optional
from unittest.mock import MagicMock
import numpy as np
import pytest
from abogen.domain.subtitle_processor import (
parse_subtitle_file,
format_time_range,
speed_up_audio,
process_subtitle_entries,
)
# --- format_time_range tests ---
class TestFormatTimeRange:
def test_basic_range(self):
result = format_time_range(0.0, 5.0)
assert result == "00:00:00 - 00:00:05"
def test_with_milliseconds(self):
result = format_time_range(1.5, 3.123)
assert "00:00:01,500" in result
assert "00:00:03,123" in result
def test_auto_end(self):
result = format_time_range(10.0, 15.0, is_auto_end=True)
assert result == "00:00:10 - AUTO"
def test_none_end(self):
result = format_time_range(5.0, None)
assert result == "00:00:05 - AUTO"
def test_hours(self):
result = format_time_range(3661.0, 3665.0)
assert result == "01:01:01 - 01:01:05"
# --- parse_subtitle_file tests ---
class TestParseSubtitleFile:
def test_parse_srt(self, tmp_path):
srt = tmp_path / "test.srt"
srt.write_text(
"1\n00:00:01,000 --> 00:00:03,000\nHello\n\n"
"2\n00:00:04,000 --> 00:00:06,000\nWorld\n",
encoding="utf-8",
)
result = parse_subtitle_file(str(srt))
assert len(result) == 2
assert result[0][2] == "Hello"
assert result[1][2] == "World"
def test_parse_vtt(self, tmp_path):
vtt = tmp_path / "test.vtt"
vtt.write_text(
"WEBVTT\n\n00:00:01.000 --> 00:00:03.000\nHello\n\n"
"00:00:04.000 --> 00:00:06.000\nWorld\n",
encoding="utf-8",
)
result = parse_subtitle_file(str(vtt))
assert len(result) == 2
def test_parse_timestamp_text(self, tmp_path):
ts = tmp_path / "test.txt"
ts.write_text(
"[00:00:01] Hello\n[00:00:04] World\n",
encoding="utf-8",
)
result = parse_subtitle_file(str(ts), is_timestamp_text=True)
assert len(result) >= 1
# --- speed_up_audio tests ---
class TestSpeedUpAudio:
def test_no_change_below_threshold(self):
audio = np.ones(24000, dtype="float32")
result = speed_up_audio(audio, 0.8, method="ffmpeg")
np.testing.assert_array_equal(result, audio)
def test_empty_audio(self):
result = speed_up_audio(np.array([], dtype="float32"), 2.0, method="ffmpeg")
assert len(result) == 0
# --- process_subtitle_entries tests ---
@dataclass
class FakeResult:
audio: np.ndarray
def fake_backend(text, voice=None, speed=1.0, split_pattern=None):
length = int(len(text) * 2400 * speed)
audio = np.random.randn(length).astype("float32") * 0.1
return [FakeResult(audio=audio)]
class TestProcessSubtitleEntries:
def test_empty_subtitles(self):
result = process_subtitle_entries(
[], backend=fake_backend, voice=None
)
assert len(result) == 0
def test_single_entry(self):
subtitles = [(0.0, 3.0, "Hello")]
result = process_subtitle_entries(
subtitles, backend=fake_backend, voice=None
)
assert len(result) > 0
assert result.dtype == np.float32
def test_cancel_check(self):
subtitles = [(0.0, 5.0, "Hello"), (5.0, 10.0, "World")]
counter = [0]
def cancel():
counter[0] += 1
return counter[0] > 1
result = process_subtitle_entries(
subtitles, backend=fake_backend, voice=None,
cancel_check=cancel,
)
# Buffer is pre-allocated but only first entry processed before cancel
assert result is not None
# Second entry should not have been mixed in (no audio at 5-10s)
assert np.max(np.abs(result[int(5.0 * 24000):])) == 0.0
def test_log_callback_called(self):
subtitles = [(0.0, 3.0, "Hello")]
logs = []
process_subtitle_entries(
subtitles, backend=fake_backend, voice=None,
log_callback=logs.append,
)
assert len(logs) >= 1
assert "Hello" in logs[0]
def test_progress_callback_called(self):
subtitles = [(0.0, 3.0, "Hello")]
progress = []
process_subtitle_entries(
subtitles, backend=fake_backend, voice=None,
progress_callback=lambda p, e: progress.append((p, e)),
)
assert len(progress) == 1
assert progress[0][0] == 99
def test_multiple_entries_mixed(self):
subtitles = [
(0.0, 2.0, "First"),
(2.0, 4.0, "Second"),
(4.0, 6.0, "Third"),
]
result = process_subtitle_entries(
subtitles, backend=fake_backend, voice=None
)
assert len(result) > 0
# Buffer should be at least as long as the last subtitle end
assert len(result) >= int(6.0 * 24000)