mirror of
https://github.com/linyqh/NarratoAI.git
synced 2026-07-22 22:18:07 +00:00
feat(jianying, webui): 新增IndexTTS2支持,优化TTS处理并添加单元测试
- 重构WebUI的TTS语音名称获取逻辑,适配多种TTS引擎 - 为IndexTTS2添加参考音频校验与归一化处理 - 新增剪映任务工具函数的完整单元测试用例 - 修复音频时长取整逻辑以提升匹配精度 - 更新默认TTS引擎为配置值而非硬编码内容
This commit is contained in:
parent
c0b72ec603
commit
283617deb0
@ -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}秒")
|
||||
|
||||
# 使用音频实际时长和视频时长中的较小值,确保不超过素材时长
|
||||
|
||||
43
app/services/test_jianying_task_unittest.py
Normal file
43
app/services/test_jianying_task_unittest.py
Normal 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()
|
||||
27
webui.py
27
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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user