Files
xiaoxia-saas/tests/unit/test_tts_voiceover.py
T
xiaoxia 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
feat: TTS文字转语音配音引擎 (#295)
2026-07-14 10:21:49 +08:00

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