c840f37a44
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 33s
CI/CD Pipeline / Frontend Lint (push) Successful in 1m12s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Failing after 1m8s
CI/CD Pipeline / Unit Tests (push) Successful in 2m52s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
414 lines
15 KiB
Python
Executable File
414 lines
15 KiB
Python
Executable File
"""TTS 配音引擎单元测试."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from apps.worker.video_processing.tts_engine import TtsEngine, VoiceoverResult, VoiceoverSegment
|
|
from packages.adapters.tts.mock_tts_service import MockTtsService
|
|
from packages.domain.tts_config import TtsConfig
|
|
from packages.domain.voice_presets import (
|
|
VoiceGender,
|
|
VoicePreset,
|
|
VoiceStyle,
|
|
get_default_voice,
|
|
get_voice,
|
|
list_voices,
|
|
)
|
|
from packages.ports.tts_service import TtsError, TtsService
|
|
|
|
# ─── TtsConfig 配置解析 ────────────────────────────────────
|
|
|
|
|
|
class TestTtsConfig:
|
|
def test_default_values(self):
|
|
config = TtsConfig()
|
|
assert config.enabled is False
|
|
assert config.voice_id == ""
|
|
assert config.speed == 1.0
|
|
assert config.pitch == 0.0
|
|
assert config.volume == 0.8
|
|
assert config.text == ""
|
|
assert config.align_mode == "full"
|
|
assert config.overlap_mode == "replace"
|
|
|
|
def test_parse_none(self):
|
|
config = TtsConfig.parse(None)
|
|
assert config.enabled is False
|
|
|
|
def test_parse_empty_dict(self):
|
|
config = TtsConfig.parse({})
|
|
assert config.enabled is False
|
|
|
|
def test_parse_enabled(self):
|
|
config = TtsConfig.parse({"enabled": True, "voice_id": "female_warm", "text": "你好"})
|
|
assert config.enabled is True
|
|
assert config.voice_id == "female_warm"
|
|
assert config.text == "你好"
|
|
|
|
def test_parse_not_enabled_ignores_other_fields(self):
|
|
config = TtsConfig.parse({"enabled": False, "voice_id": "test", "speed": 2.0})
|
|
assert config.enabled is False
|
|
assert config.voice_id == ""
|
|
assert config.speed == 1.0
|
|
|
|
def test_parse_speed_boundary(self):
|
|
config = TtsConfig.parse({"enabled": True, "speed": 0.1})
|
|
assert config.speed == 0.5
|
|
|
|
config = TtsConfig.parse({"enabled": True, "speed": 5.0})
|
|
assert config.speed == 2.0
|
|
|
|
def test_parse_pitch_boundary(self):
|
|
config = TtsConfig.parse({"enabled": True, "pitch": -20})
|
|
assert config.pitch == -12
|
|
|
|
config = TtsConfig.parse({"enabled": True, "pitch": 20})
|
|
assert config.pitch == 12
|
|
|
|
def test_parse_volume_boundary(self):
|
|
config = TtsConfig.parse({"enabled": True, "volume": -1.0})
|
|
assert config.volume == 0.0
|
|
|
|
config = TtsConfig.parse({"enabled": True, "volume": 2.0})
|
|
assert config.volume == 1.0
|
|
|
|
def test_parse_invalid_types(self):
|
|
config = TtsConfig.parse(
|
|
{
|
|
"enabled": True,
|
|
"speed": "fast",
|
|
"pitch": "high",
|
|
"volume": "loud",
|
|
"voice_id": 123,
|
|
"text": 456,
|
|
"align_mode": "invalid",
|
|
"overlap_mode": "invalid",
|
|
}
|
|
)
|
|
assert config.speed == 1.0
|
|
assert config.pitch == 0.0
|
|
assert config.volume == 0.8
|
|
assert config.voice_id == ""
|
|
assert config.text == ""
|
|
assert config.align_mode == "full"
|
|
assert config.overlap_mode == "replace"
|
|
|
|
|
|
# ─── 预设音色库 ───────────────────────────────────────────
|
|
|
|
|
|
class TestPresetVoices:
|
|
def test_list_voices_all(self):
|
|
voices = list_voices()
|
|
assert len(voices) >= 6
|
|
|
|
def test_get_voice_existing(self):
|
|
voice = get_voice("female_warm")
|
|
assert voice is not None
|
|
assert voice.voice_id == "female_warm"
|
|
assert voice.name == "温暖女声"
|
|
assert voice.gender == VoiceGender.FEMALE
|
|
|
|
def test_get_voice_not_found(self):
|
|
assert get_voice("nonexistent") is None
|
|
|
|
def test_get_default_voice(self):
|
|
voice = get_default_voice()
|
|
assert voice is not None
|
|
assert voice.provider == "mock"
|
|
|
|
def test_filter_by_gender(self):
|
|
female = list_voices(gender="female")
|
|
assert len(female) >= 2
|
|
for v in female:
|
|
assert v.gender == VoiceGender.FEMALE
|
|
|
|
male = list_voices(gender="male")
|
|
assert len(male) >= 2
|
|
for v in male:
|
|
assert v.gender == VoiceGender.MALE
|
|
|
|
def test_filter_by_style(self):
|
|
stable = list_voices(style="stable")
|
|
for v in stable:
|
|
assert v.style == VoiceStyle.STABLE
|
|
|
|
def test_filter_by_keyword(self):
|
|
result = list_voices(keyword="女声")
|
|
assert len(result) >= 1
|
|
for v in result:
|
|
assert "女" in v.name
|
|
|
|
def test_voice_fields(self):
|
|
voice = get_voice("male_stable")
|
|
assert voice is not None
|
|
assert voice.name
|
|
assert voice.voice_id
|
|
assert voice.description
|
|
assert voice.sample_rate > 0
|
|
|
|
|
|
# ─── Mock TTS 服务 ────────────────────────────────────────
|
|
|
|
|
|
class TestMockTtsService:
|
|
def setup_method(self):
|
|
self.service = MockTtsService()
|
|
|
|
def test_provider_name(self):
|
|
assert self.service.provider_name == "mock"
|
|
|
|
def test_available_voices(self):
|
|
voices = self.service.available_voices()
|
|
assert len(voices) >= 6
|
|
|
|
def test_synthesize_success(self, tmp_path):
|
|
output = tmp_path / "test.wav"
|
|
result = self.service.synthesize(
|
|
"测试文本一二三四五",
|
|
voice_id="female_warm",
|
|
speed=1.0,
|
|
output_path=output,
|
|
)
|
|
assert result == output
|
|
assert result.exists()
|
|
assert result.stat().st_size > 0
|
|
|
|
def test_synthesize_different_voices(self, tmp_path):
|
|
voices = ["female_warm", "male_stable", "child_cute"]
|
|
for vid in voices:
|
|
output = tmp_path / f"{vid}.wav"
|
|
result = self.service.synthesize("测试", voice_id=vid, output_path=output)
|
|
assert result.exists()
|
|
|
|
def test_synthesize_speed_faster(self, tmp_path):
|
|
"""语速快应该时长短."""
|
|
out_slow = tmp_path / "slow.wav"
|
|
out_fast = tmp_path / "fast.wav"
|
|
text = "一二三四五六七八九十"
|
|
|
|
self.service.synthesize(text, speed=0.5, output_path=out_slow)
|
|
self.service.synthesize(text, speed=2.0, output_path=out_fast)
|
|
|
|
# 快速应该文件更小(时长短)
|
|
size_slow = out_slow.stat().st_size
|
|
size_fast = out_fast.stat().st_size
|
|
assert size_fast < size_slow
|
|
|
|
def test_synthesize_pitch_changes(self, tmp_path):
|
|
output = tmp_path / "high_pitch.wav"
|
|
result = self.service.synthesize("测试", voice_id="female_warm", pitch=6, output_path=output)
|
|
assert result.exists()
|
|
|
|
def test_synthesize_empty_text_raises(self):
|
|
with pytest.raises(TtsError):
|
|
self.service.synthesize("")
|
|
|
|
def test_synthesize_whitespace_text_raises(self):
|
|
with pytest.raises(TtsError):
|
|
self.service.synthesize(" ")
|
|
|
|
def test_estimate_duration(self):
|
|
dur = self.service.estimate_duration("一二三四五")
|
|
assert dur > 0
|
|
assert dur < 10 # 5个字应该少于10秒
|
|
|
|
def test_estimate_duration_speed(self):
|
|
text = "一二三四五六七八九十"
|
|
dur_normal = self.service.estimate_duration(text, speed=1.0)
|
|
dur_fast = self.service.estimate_duration(text, speed=2.0)
|
|
dur_slow = self.service.estimate_duration(text, speed=0.5)
|
|
|
|
assert dur_fast < dur_normal
|
|
assert dur_slow > dur_normal
|
|
|
|
def test_synthesize_unknown_voice_fallback(self, tmp_path):
|
|
output = tmp_path / "fallback.wav"
|
|
# 未知音色应该 fallback 到默认音色,不报错
|
|
result = self.service.synthesize("测试", voice_id="unknown_voice", output_path=output)
|
|
assert result.exists()
|
|
|
|
|
|
# ─── TtsEngine 配音引擎 ──────────────────────────────────
|
|
|
|
|
|
class TestTtsEngine:
|
|
def _make_engine(self, tmp_path):
|
|
service = MockTtsService()
|
|
work_dir = tmp_path / "tts_engine"
|
|
return TtsEngine(service, work_dir)
|
|
|
|
def test_generate_full_voiceover_disabled(self, tmp_path):
|
|
engine = self._make_engine(tmp_path)
|
|
config = TtsConfig(enabled=False)
|
|
result = engine.generate_full_voiceover(config)
|
|
assert result.success is False
|
|
|
|
def test_generate_full_voiceover_empty_text(self, tmp_path):
|
|
engine = self._make_engine(tmp_path)
|
|
config = TtsConfig(enabled=True, text="")
|
|
result = engine.generate_full_voiceover(config)
|
|
assert result.success is False
|
|
|
|
def test_generate_full_voiceover_success(self, tmp_path):
|
|
engine = self._make_engine(tmp_path)
|
|
config = TtsConfig(
|
|
enabled=True,
|
|
voice_id="female_warm",
|
|
text="这是一段测试配音文本",
|
|
)
|
|
result = engine.generate_full_voiceover(config)
|
|
assert result.success is True
|
|
assert len(result.segments) == 1
|
|
assert result.total_duration > 0
|
|
assert result.segments[0].audio_path is not None
|
|
assert result.segments[0].audio_path.exists()
|
|
assert result.segments[0].duration > 0
|
|
|
|
def test_generate_full_voiceover_with_speed(self, tmp_path):
|
|
engine = self._make_engine(tmp_path)
|
|
config_slow = TtsConfig(
|
|
enabled=True,
|
|
voice_id="female_warm",
|
|
text="测试文本一二三四五六七八九十",
|
|
speed=0.5,
|
|
)
|
|
config_fast = TtsConfig(
|
|
enabled=True,
|
|
voice_id="female_warm",
|
|
text="测试文本一二三四五六七八九十",
|
|
speed=2.0,
|
|
)
|
|
result_slow = engine.generate_full_voiceover(config_slow)
|
|
result_fast = engine.generate_full_voiceover(config_fast)
|
|
|
|
assert result_slow.success
|
|
assert result_fast.success
|
|
# 慢速时长 > 快速时长
|
|
assert result_slow.total_duration > result_fast.total_duration
|
|
|
|
def test_generate_subtitle_voiceover_empty_subtitles(self, tmp_path):
|
|
engine = self._make_engine(tmp_path)
|
|
config = TtsConfig(enabled=True, voice_id="female_warm")
|
|
result = engine.generate_subtitle_voiceover(config, [])
|
|
assert result.success is False
|
|
|
|
def test_generate_subtitle_voiceover_success(self, tmp_path):
|
|
engine = self._make_engine(tmp_path)
|
|
config = TtsConfig(enabled=True, voice_id="female_warm", align_mode="subtitle")
|
|
subtitles = [
|
|
{"text": "大家好", "start_time": 0, "end_time": 2},
|
|
{"text": "欢迎观看", "start_time": 2, "end_time": 4},
|
|
{"text": "今天的视频", "start_time": 4, "end_time": 6},
|
|
]
|
|
result = engine.generate_subtitle_voiceover(config, subtitles)
|
|
assert result.success is True
|
|
assert len(result.segments) == 3
|
|
|
|
# 每个片段的 start_time 应该对应字幕的开始时间
|
|
assert result.segments[0].start_time == 0
|
|
assert result.segments[1].start_time == 2
|
|
assert result.segments[2].start_time == 4
|
|
|
|
for seg in result.segments:
|
|
assert seg.audio_path is not None
|
|
assert seg.audio_path.exists()
|
|
assert seg.duration > 0
|
|
|
|
def test_generate_subtitle_voiceover_skips_empty(self, tmp_path):
|
|
engine = self._make_engine(tmp_path)
|
|
config = TtsConfig(enabled=True, voice_id="female_warm")
|
|
subtitles = [
|
|
{"text": "有文本", "start_time": 0, "end_time": 1},
|
|
{"text": "", "start_time": 1, "end_time": 2},
|
|
{"text": "也有文本", "start_time": 2, "end_time": 3},
|
|
]
|
|
result = engine.generate_subtitle_voiceover(config, subtitles)
|
|
assert result.success is True
|
|
assert len(result.segments) == 2 # 跳过了空文本
|
|
|
|
def test_generate_full_voiceover_failure_graceful(self, tmp_path, monkeypatch):
|
|
"""失败时优雅降级,不抛出异常."""
|
|
engine = self._make_engine(tmp_path)
|
|
|
|
def failing_synth(*args, **kwargs):
|
|
raise TtsError("模拟失败")
|
|
|
|
monkeypatch.setattr(engine._tts, "synthesize", failing_synth)
|
|
|
|
config = TtsConfig(enabled=True, voice_id="test", text="测试")
|
|
result = engine.generate_full_voiceover(config)
|
|
assert result.success is False
|
|
assert result.error_message
|
|
assert "模拟失败" in result.error_message
|
|
|
|
def test_generate_subtitle_voiceover_partial_failure(self, tmp_path, monkeypatch):
|
|
"""部分片段失败时跳过,其他正常生成."""
|
|
engine = self._make_engine(tmp_path)
|
|
original_synth = engine._tts.synthesize
|
|
call_count = [0]
|
|
|
|
def sometimes_fail(*args, **kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] == 2: # 第2个片段失败
|
|
raise TtsError("模拟失败")
|
|
return original_synth(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(engine._tts, "synthesize", sometimes_fail)
|
|
|
|
config = TtsConfig(enabled=True, voice_id="female_warm")
|
|
subtitles = [
|
|
{"text": "第一段", "start_time": 0, "end_time": 2},
|
|
{"text": "第二段失败", "start_time": 2, "end_time": 4},
|
|
{"text": "第三段", "start_time": 4, "end_time": 6},
|
|
]
|
|
result = engine.generate_subtitle_voiceover(config, subtitles)
|
|
# 有部分成功就算成功
|
|
assert result.success is True
|
|
assert len(result.segments) == 2 # 跳过了失败的第2段
|
|
|
|
def test_build_audio_mix_filter_empty(self, tmp_path):
|
|
engine = self._make_engine(tmp_path)
|
|
result = VoiceoverResult(success=False)
|
|
filter_str, files = engine.build_audio_mix_filter(result, video_duration=10)
|
|
assert filter_str == ""
|
|
assert files == []
|
|
|
|
def test_build_audio_mix_filter_single(self, tmp_path):
|
|
engine = self._make_engine(tmp_path)
|
|
audio_file = tmp_path / "seg.wav"
|
|
audio_file.write_bytes(b"fake")
|
|
|
|
segment = VoiceoverSegment(
|
|
text="test",
|
|
start_time=1.0,
|
|
end_time=3.0,
|
|
audio_path=audio_file,
|
|
duration=2.0,
|
|
)
|
|
result = VoiceoverResult(success=True, segments=[segment], total_duration=3.0)
|
|
|
|
filter_str, files = engine.build_audio_mix_filter(result, video_duration=10)
|
|
assert len(files) == 1
|
|
assert "adelay" in filter_str
|
|
assert "1000" in filter_str # 1秒 = 1000ms
|
|
|
|
|
|
# ─── VoicePreset 数据类 ──────────────────────────────────
|
|
|
|
|
|
class TestVoicePreset:
|
|
def test_create_preset(self):
|
|
preset = VoicePreset(
|
|
voice_id="test_voice",
|
|
name="测试音色",
|
|
gender=VoiceGender.MALE,
|
|
style=VoiceStyle.NARRATION,
|
|
)
|
|
assert preset.voice_id == "test_voice"
|
|
assert preset.name == "测试音色"
|
|
assert preset.gender == VoiceGender.MALE
|
|
assert preset.style == VoiceStyle.NARRATION
|
|
assert preset.sample_rate == 22050
|