feat(jianying, webui): 新增IndexTTS2支持,优化TTS处理并添加单元测试

- 重构WebUI的TTS语音名称获取逻辑,适配多种TTS引擎
- 为IndexTTS2添加参考音频校验与归一化处理
- 新增剪映任务工具函数的完整单元测试用例
- 修复音频时长取整逻辑以提升匹配精度
- 更新默认TTS引擎为配置值而非硬编码内容
This commit is contained in:
viccy 2026-06-05 14:57:00 +08:00
parent c0b72ec603
commit 283617deb0
3 changed files with 97 additions and 6 deletions

View File

@ -43,6 +43,37 @@ def get_audio_duration_ffprobe(audio_file: str) -> float:
raise
def _strip_indextts2_prefix(voice_name: str) -> str:
prefix = "indextts2:"
if voice_name.startswith(prefix):
return voice_name[len(prefix):]
return voice_name
def _floor_duration_to_milliseconds(duration: float) -> float:
return int(duration * 1000) / 1000.0
def _normalize_indextts2_reference_audio(params: VideoClipParams) -> None:
"""Ensure IndexTTS2 uses the configured reference audio instead of a stale UI voice."""
if params.tts_engine != "indextts2":
return
candidate = _strip_indextts2_prefix(getattr(params, "voice_name", "") or "")
if candidate and os.path.isfile(candidate):
params.voice_name = f"indextts2:{candidate}"
logger.info(f"IndexTTS2 使用参考音频: {candidate}")
return
configured_ref = _strip_indextts2_prefix(config.indextts2.get("reference_audio", "") or "")
if configured_ref and os.path.isfile(configured_ref):
params.voice_name = f"indextts2:{configured_ref}"
logger.info(f"IndexTTS2 使用配置中的参考音频: {configured_ref}")
return
raise ValueError("IndexTTS2 参考音频不存在,请在音频设置中上传或填写有效的参考音频路径")
def start_export_jianying_draft(task_id: str, params: VideoClipParams):
"""
导出到剪映草稿的后台任务
@ -83,6 +114,7 @@ def start_export_jianying_draft(task_id: str, params: VideoClipParams):
2. 使用 TTS 生成音频素材
"""
logger.info("\n\n## 2. 根据OST设置生成音频列表")
_normalize_indextts2_reference_audio(params)
tts_segments = [
segment for segment in list_script
if segment['OST'] in [0, 2]
@ -199,6 +231,7 @@ def start_export_jianying_draft(task_id: str, params: VideoClipParams):
if os.path.exists(audio_file):
# 使用ffprobe获取精确的音频时长避免因TTS引擎差异导致时长不匹配
actual_audio_duration = get_audio_duration_ffprobe(audio_file)
actual_audio_duration = _floor_duration_to_milliseconds(actual_audio_duration)
logger.info(f"音频文件实际时长: {actual_audio_duration:.6f}秒, 脚本时长(视频): {duration:.3f}")
# 使用音频实际时长和视频时长中的较小值,确保不超过素材时长

View File

@ -0,0 +1,43 @@
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from app.models.schema import VideoClipParams
from app.services import jianying_task
class JianyingTaskTests(unittest.TestCase):
def test_normalize_indextts2_uses_valid_param_reference(self):
with tempfile.NamedTemporaryFile(suffix=".wav") as ref:
params = VideoClipParams(tts_engine="indextts2", voice_name=ref.name)
jianying_task._normalize_indextts2_reference_audio(params)
self.assertEqual(f"indextts2:{ref.name}", params.voice_name)
def test_normalize_indextts2_uses_config_reference_when_param_is_stale(self):
with tempfile.TemporaryDirectory() as temp_dir:
ref_path = Path(temp_dir) / "reference.wav"
ref_path.write_bytes(b"fake wav")
params = VideoClipParams(tts_engine="indextts2", voice_name="zh-CN-YunjianNeural")
with patch.dict(jianying_task.config.indextts2, {"reference_audio": str(ref_path)}, clear=False):
jianying_task._normalize_indextts2_reference_audio(params)
self.assertEqual(f"indextts2:{ref_path}", params.voice_name)
def test_normalize_indextts2_requires_existing_reference_audio(self):
params = VideoClipParams(tts_engine="indextts2", voice_name="zh-CN-YunjianNeural")
with patch.dict(jianying_task.config.indextts2, {"reference_audio": ""}, clear=False):
with self.assertRaisesRegex(ValueError, "IndexTTS2 参考音频不存在"):
jianying_task._normalize_indextts2_reference_audio(params)
def test_floor_duration_to_milliseconds(self):
self.assertAlmostEqual(6.997, jianying_task._floor_duration_to_milliseconds(6.997333))
self.assertAlmostEqual(7.0, jianying_task._floor_duration_to_milliseconds(7.000999))
if __name__ == "__main__":
unittest.main()

View File

@ -224,17 +224,32 @@ def render_generate_button():
def get_voice_name_for_tts_engine(tts_engine: str) -> str:
"""根据TTS引擎获取用户选择的音色"""
if tts_engine == 'edge_tts':
return config.ui.get('edge_voice_name', 'zh-CN-XiaoxiaoNeural-Female')
if tts_engine == 'azure_speech':
return config.ui.get('azure_voice_name', 'zh-CN-XiaoxiaoMultilingualNeural')
if tts_engine == 'tencent_tts':
return f"tencent:{config.ui.get('tencent_voice_type', '101001')}"
if tts_engine == 'qwen3_tts':
return f"qwen3:{config.ui.get('qwen_voice_type', 'Cherry')}"
if tts_engine == 'indextts2':
reference_audio = config.indextts2.get('reference_audio', '')
if reference_audio:
return f"indextts2:{reference_audio}"
return config.ui.get('voice_name', '')
if tts_engine == 'doubaotts':
return st.session_state.get('voice_name', config.ui.get('doubaotts_voice_type', 'BV700_streaming'))
elif tts_engine == 'azure_speech':
return st.session_state.get('voice_name', config.ui.get('azure_voice_name', 'zh-CN-XiaoxiaoMultilingualNeural'))
else:
return st.session_state.get('voice_name', config.ui.get('edge_voice_name', 'zh-CN-XiaoxiaoNeural-Female'))
return config.ui.get('doubaotts_voice_type', 'BV700_streaming')
if tts_engine == 'soulvoice':
voice_uri = config.soulvoice.get('voice_uri', '')
if voice_uri and not voice_uri.startswith(('soulvoice:', 'speech:')):
return f"soulvoice:{voice_uri}"
return voice_uri
return config.ui.get('voice_name', config.ui.get('edge_voice_name', 'zh-CN-XiaoxiaoNeural-Female'))
def get_jianying_export_params() -> VideoClipParams:
"""获取导出到剪映草稿的参数"""
tts_engine = st.session_state.get('tts_engine', 'azure')
tts_engine = st.session_state.get('tts_engine', config.ui.get('tts_engine', 'edge_tts'))
voice_name = get_voice_name_for_tts_engine(tts_engine)
voice_rate = st.session_state.get('voice_rate', 1.0)
voice_pitch = st.session_state.get('voice_pitch', 1.0)