"""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