mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-22 07:10:28 +02:00
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:
@@ -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
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user