From 283617deb001f099e9861c4755a74844ca726a38 Mon Sep 17 00:00:00 2001 From: viccy Date: Fri, 5 Jun 2026 14:57:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(jianying,=20webui):=20=E6=96=B0=E5=A2=9EIn?= =?UTF-8?q?dexTTS2=E6=94=AF=E6=8C=81=EF=BC=8C=E4=BC=98=E5=8C=96TTS?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构WebUI的TTS语音名称获取逻辑,适配多种TTS引擎 - 为IndexTTS2添加参考音频校验与归一化处理 - 新增剪映任务工具函数的完整单元测试用例 - 修复音频时长取整逻辑以提升匹配精度 - 更新默认TTS引擎为配置值而非硬编码内容 --- app/services/jianying_task.py | 33 ++++++++++++++++ app/services/test_jianying_task_unittest.py | 43 +++++++++++++++++++++ webui.py | 27 ++++++++++--- 3 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 app/services/test_jianying_task_unittest.py diff --git a/app/services/jianying_task.py b/app/services/jianying_task.py index 25b4a74..f06d2f0 100644 --- a/app/services/jianying_task.py +++ b/app/services/jianying_task.py @@ -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}秒") # 使用音频实际时长和视频时长中的较小值,确保不超过素材时长 diff --git a/app/services/test_jianying_task_unittest.py b/app/services/test_jianying_task_unittest.py new file mode 100644 index 0000000..24a87fe --- /dev/null +++ b/app/services/test_jianying_task_unittest.py @@ -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() diff --git a/webui.py b/webui.py index d2ab42b..3b9c12b 100644 --- a/webui.py +++ b/webui.py @@ -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)