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: if duration_seconds <= 0:
return 0 return 0
return int(round(duration_seconds * sample_rate)) 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 from __future__ import annotations
import os
import platform import platform
import re import re
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Callable, List, Optional, Tuple from typing import Any, Callable, List, Optional, Tuple
from abogen.subtitle_utils import sanitize_name_for_os
from abogen.text_extractor import ExtractedChapter 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, audio_dir, subtitle_dir, metadata_dir
return project_root, project_root, project_root, None 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.infrastructure.subtitle_writer import create_subtitle_writer
from abogen.domain.split_pattern import get_split_pattern 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 ( from abogen.domain.output_paths import (
resolve_output_directory, resolve_output_directory,
build_output_path, build_output_path,
sanitize_output_stem, sanitize_output_stem,
sanitize_filename_for_chapter, sanitize_filename_for_chapter,
resolve_unique_path,
) )
from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32 from abogen.domain.audio_helpers import build_ffmpeg_command, to_float32
from abogen.domain.audio_sink import AudioSink, open_audio_sink from abogen.domain.audio_sink import AudioSink, open_audio_sink
@@ -79,11 +84,7 @@ _USER_RESPONSE_TIMEOUT = (
from abogen.subtitle_utils import ( from abogen.subtitle_utils import (
clean_text, clean_text,
parse_srt_file,
parse_vtt_file,
detect_timestamps_in_text, detect_timestamps_in_text,
parse_timestamp_text_file,
parse_ass_file,
get_sample_voice_text, get_sample_voice_text,
sanitize_name_for_os, sanitize_name_for_os,
split_text_by_voice_markers split_text_by_voice_markers
@@ -1178,16 +1179,7 @@ class ConversionThread(QThread):
"""Process subtitle files with precise timing and generate output subtitles.""" """Process subtitle files with precise timing and generate output subtitles."""
try: try:
# Parse subtitle file # Parse subtitle file
if is_timestamp_text: subtitles = parse_subtitle_file(self.file_name, 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)
if not subtitles: if not subtitles:
self.log_updated.emit(("No valid subtitle entries found.", "red")) self.log_updated.emit(("No valid subtitle entries found.", "red"))
@@ -1202,7 +1194,6 @@ class ConversionThread(QThread):
# Setup output paths # Setup output paths
base_name = os.path.splitext(os.path.basename(base_path))[0] 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 = ( parent_dir = (
user_desktop_dir() user_desktop_dir()
if self.save_option == "Save to Desktop" if self.save_option == "Save to Desktop"
@@ -1219,24 +1210,11 @@ class ConversionThread(QThread):
) )
return return
# Find unique filename
counter = 1
allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS) allowed_exts = set(SUPPORTED_SOUND_FORMATS + SUPPORTED_SUBTITLE_FORMATS)
while True: sanitized_base_path = resolve_unique_path(
suffix = f"_{counter}" if counter > 1 else "" parent_dir, base_name, self.output_format, allowed_exts
# 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}"
) )
base_filepath_no_ext = sanitized_base_path
merged_out_path = f"{base_filepath_no_ext}.{self.output_format}" merged_out_path = f"{base_filepath_no_ext}.{self.output_format}"
rate = 24000 rate = 24000
@@ -1265,81 +1243,20 @@ class ConversionThread(QThread):
# Load voice # Load voice
loaded_voice = resolve_voice(self.voice, tts, self.use_gpu) loaded_voice = resolve_voice(self.voice, tts, self.use_gpu)
# Calculate initial audio buffer size from timed subtitles only # Process all subtitles via domain
max_end_time = max( audio_buffer = process_subtitle_entries(
(end for _, end, _ in subtitles if end is not None), default=0 subtitles,
) backend=self.backend,
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,
voice=loaded_voice, voice=loaded_voice,
speed=self.speed, 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 self.cancel_requested:
if subtitle_writer: if subtitle_writer:
@@ -1347,160 +1264,16 @@ class ConversionThread(QThread):
self.conversion_finished.emit("Cancelled", None) self.conversion_finished.emit("Cancelled", None)
return return
# Concatenate audio and determine duration # Write subtitle entries (post-loop)
full_audio = ( for start_time, end_time, text in subtitles:
np.concatenate( processed_text = text.replace("\n", " ") if getattr(self, "replace_single_newlines", True) else text
[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:
display_text = ( display_text = (
processed_text processed_text
if "ass" in subtitle_format or replace_nl if "ass" in subtitle_format
else processed_text.replace("\n", "\\N") else processed_text.replace("\n", "\\N")
) )
subtitle_writer.write_entry(start_time, end_time, display_text) 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 # Write the complete audio buffer
self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey")) self.log_updated.emit(("\nFinalizing audio. Please wait...", "grey"))
if merged_sink: 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)