diff --git a/apps/api/app/api/routes/lipsync.py b/apps/api/app/api/routes/lipsync.py index ef8359f6a..7889259ea 100644 --- a/apps/api/app/api/routes/lipsync.py +++ b/apps/api/app/api/routes/lipsync.py @@ -111,7 +111,9 @@ def create_lipsync_job( voice_id=body.voice_id, script_text=body.script_text, speed=body.speed, + volume=body.volume if hasattr(body, "volume") and body.volume is not None else 50, emotion=body.emotion, + style=body.style if hasattr(body, "style") else "", enable_video_loop=body.enable_video_loop, project_id=body.project_id, ) @@ -212,6 +214,7 @@ def preview_tts( script_text=body.script_text, speed=body.speed, emotion=body.emotion, + style=getattr(body, "style", "") or "", ) except MediaKitError as exc: if _points_deducted > 0 and _points_svc is not None: diff --git a/apps/api/app/api/routes/tts.py b/apps/api/app/api/routes/tts.py index a01c33d12..30d824603 100644 --- a/apps/api/app/api/routes/tts.py +++ b/apps/api/app/api/routes/tts.py @@ -203,10 +203,12 @@ def synthesize( # job.voice_id 统一存解析后的 CosyVoice voice_id actual_voice_id = resolved_profile.voice_id - # 语速/情绪等合成参数随 metadata 落库,workflow 提交 CosyVoice 时读取透传 + # 语速/情绪/风格/音量等合成参数随 metadata 落库,workflow 提交 CosyVoice 时读取透传 synthesis_meta = { "speed": request.speed, + "volume": request.volume if request.volume is not None else 50, "emotion": request.emotion or "", + "style": request.style or "", "language": request.language or "zh-CN", } if request.metadata_: @@ -655,7 +657,9 @@ def preview_tts( voice_id=actual_voice_id, speed=request.speed, emotion=request.emotion, + style=getattr(request, "style", "") or "", language=getattr(request, "language", "zh-CN"), + pitch=getattr(request, "pitch", 1.0), ) except (CosyVoiceError, ValueError) as e: # 合成失败退费 diff --git a/apps/api/app/schemas/lipsync.py b/apps/api/app/schemas/lipsync.py index 8bd9ef9bc..9f3d16783 100644 --- a/apps/api/app/schemas/lipsync.py +++ b/apps/api/app/schemas/lipsync.py @@ -67,10 +67,15 @@ class CreateLipsyncJobRequest(BaseModel): voice_id: str = Field("", description="音色 ID(预置音色或克隆音色 profile UUID)") script_text: str = Field("", description="要合成的文案(直生模式必填,最长 5000 字符)") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速(0.5-2.0),默认 1.0") + volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50") emotion: str = Field( "", description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;空为默认自然)", ) + style: Optional[str] = Field( + "", + description="语气风格(natural/excited/professional/gentle/news/livestream),可选;与 emotion 互斥,style 优先级更高", + ) enable_video_loop: bool = Field( True, description="音频长于视频时是否循环画面(AI数字人默认开启,防止音频长于视频被截断)" @@ -128,6 +133,10 @@ class AiAvatarTtsPreviewRequest(BaseModel): max_length=32, description="情绪(英文枚举 neutral/happy/sad/angry/surprised/fearful/disgusted,或中文 中立/开心/难过/生气/惊讶/恐惧/厌恶;默认 neutral)", ) + style: Optional[str] = Field( + "", + description="语气风格(natural/excited/professional/gentle/news/livestream),可选;与 emotion 互斥,style 优先级更高", + ) class AiAvatarTtsPreviewResponse(BaseModel): diff --git a/apps/api/app/schemas/tts.py b/apps/api/app/schemas/tts.py index 39dd4f380..18bdeb607 100644 --- a/apps/api/app/schemas/tts.py +++ b/apps/api/app/schemas/tts.py @@ -16,10 +16,15 @@ class TTSSynthesizeRequest(BaseModel): output_name: str = Field("", description="输出文件名") language: str = Field("zh-CN", description="语言") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速") + volume: Optional[int] = Field(None, ge=0, le=100, description="音量(0-100),默认 50") emotion: str = Field( "", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等;通过 instruction 自然语言指令控制)", ) + style: Optional[str] = Field( + "", + description="语气风格(natural/excited/professional/gentle/news/livestream),可选;与 emotion 互斥,style 优先级更高", + ) voice_model: str = Field("", description="语音模型名称") voice_clone_profile_id: str = Field("", description="关联的音色克隆档案 ID") format: str = Field("mp3", description="输出格式(mp3/wav/pcm)") @@ -114,8 +119,12 @@ class TTSPreviewRequest(BaseModel): voice_id: str = Field(..., min_length=1, description="音色 ID") speed: float = Field(1.0, ge=0.5, le=2.0, description="语速") emotion: str = Field("", description="情绪(中文/英文:自然/兴奋/沉稳/亲切/开心/悲伤/愤怒/惊讶/恐惧/厌恶 等)") + style: Optional[str] = Field( + "", + description="语气风格(natural/excited/professional/gentle/news/livestream),可选;与 emotion 互斥,style 优先级更高", + ) language: str = Field("zh-CN", description="语言(zh-CN/en-US 等)") - pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(预留,当前未使用)") + pitch: float = Field(1.0, ge=0.5, le=2.0, description="音调(0.5-2.0),1.0 为默认值") class TTSPreviewResponse(BaseModel): diff --git a/apps/api/app/services/lipsync_service.py b/apps/api/app/services/lipsync_service.py index 517587e38..825474ca3 100644 --- a/apps/api/app/services/lipsync_service.py +++ b/apps/api/app/services/lipsync_service.py @@ -424,7 +424,9 @@ class LipsyncService: voice_id: str = "", script_text: str = "", speed: float = 1.0, + volume: int = 50, emotion: str = "", + style: str = "", enable_video_loop: bool = True, project_id: str = "", ) -> LipsyncJobModel: @@ -494,6 +496,8 @@ class LipsyncService: script_text, speed, emotion or "", + volume, + style or "", ) ) except Exception as exc: @@ -528,6 +532,7 @@ class LipsyncService: script_text: str, speed: float = 1.0, emotion: str = "neutral", + style: str = "", ) -> dict: """同步做 TTS 合成 + 下载 + ffprobe + 句子时间戳计算. @@ -552,6 +557,7 @@ class LipsyncService: speed=speed, emotion=emotion, # normalize 在 CosyVoiceService 内部完成 language="zh", + style=style, ) except CosyVoiceError as exc: raise MediaKitError(f"TTS 合成失败: {exc}", code="TTSSynthesisFailed") from exc diff --git a/apps/api/app/tasks/lipsync_tts.py b/apps/api/app/tasks/lipsync_tts.py index dce535485..c2c5dcb75 100644 --- a/apps/api/app/tasks/lipsync_tts.py +++ b/apps/api/app/tasks/lipsync_tts.py @@ -83,6 +83,8 @@ def tts_synthesize_and_submit( script_text: str, speed: float, emotion: str, + volume: int = 50, + style: str = "", ): """异步执行 TTS 合成 + OSS 转存 + MediaKit 提交. @@ -161,6 +163,8 @@ def tts_synthesize_and_submit( speed=speed, emotion=emotion, language="zh", + volume=volume, + style=style, ) except CosyVoiceError as exc: logger.error("[lipsync_tts] TTS 合成失败: job_id=%s err=%s", job_id, exc) diff --git a/deploy/gpu_worker/MUSELIP_ANALYSIS.md b/deploy/gpu_worker/MUSELIP_ANALYSIS.md new file mode 100644 index 000000000..a68499962 --- /dev/null +++ b/deploy/gpu_worker/MUSELIP_ANALYSIS.md @@ -0,0 +1,218 @@ +# MuseTalk 口型精度问题分析与修复方案 + +## 问题总结 + +运维逐帧分析确认: +- ✅ 音频对齐正常(0ms偏移,相关系数0.99997) +- ❌ **口型只跟音频能量张合,没有音素级精度**(发o/u没有圆唇,发m/b/p没有闭唇,静音段嘴巴还张着) +- ❌ 5s循环边界有视觉跳变(帧间差异是正常的3-4倍) + +## 根因分析 + +### 根因 1:`musetalk_server.py` 的 `_run_inference()` 是 STUB 代码(最严重) + +`deploy/gpu_worker/musetalk_server.py` 第 343-466 行: + +```python +# 2. 模拟 MuseTalk 推理:输入原视频帧 + 全量音频,输出音频时长的无声画面。 +# TODO: 替换为 MuseTalk 真实推理逻辑。 +logger.warning("使用示例推理逻辑,未实际调用 MuseTalk 模型") +``` + +**当前代码从未调用 MuseTalk 模型**。整个推理流程是: +1. 从视频提取帧(ffmpeg) +2. 生成一段循环原视频的无声画面(ffmpeg `-stream_loop`) +3. 用 ffmpeg `-c:v copy` 封装音频 + +**根本没有 MuseTalk 推理!** 所以"口型只跟能量张合"可能是因为 MuseTalk 的实际推理代码没被调用。 + +### 根因 2:音频格式未预处理 + +`gpu_worker.py` 把音频下载为 `input_audio.bin`(第 360 行),直接透传给 `musetalk_server.py`。 +`musetalk_server.py` 保存为 `input_audio.wav`(第 527 行)但**没有做任何格式转换**。 + +CosyVoice TTS 输出的音频格式: +- **采样率**: 22050Hz(`cosyvoice_sample_rate = 22050`) +- **格式**: MP3(`cosyvoice_format = "mp3"`) + +MuseTalk 期望的输入: +- **采样率**: 16000Hz +- **声道**: mono +- **格式**: WAV(16bit PCM) + +**22050Hz MP3 → 16kHz mono 16bit WAV 的重采样转换完全没有做**。 + +这会导致 MuseTalk 的 whisper feature extractor 拿到错误采样率的音频,提取的 mel 频谱特征频率错位,音素识别完全错误。 + +### 根因 3:循环边界无过渡帧 + +`_mux_video_with_audio()` 的兜底路径(第 241-286 行)用 `ffmpeg -stream_loop -1` 直接循环视频,循环边界处: +- 最后一帧 → 第一帧:没有过渡,硬切 +- 帧间差异是正常帧的 3-4 倍 + +## 修复方案 + +### 修复 1:实现真正的 MuseTalk 推理调用 + +`_run_inference()` 必须调用真实的 MuseTalk 模型。参考 MuseTalk 官方推理流程: + +```python +def _run_inference_real(video_path, audio_path, output_path): + """真正的 MuseTalk 推理 — 替换 stub。""" + # 1. 音频预处理:转换为 16kHz mono 16bit WAV + preprocessed_audio = audio_path.parent / "audio_16k_mono.wav" + _preprocess_audio(audio_path, preprocessed_audio) + + # 2. 调用 MuseTalk 推理 + # MuseTalk 代码在 ~/projects/MuseTalk/ 下 + import sys + muse_dir = Path.home() / "projects" / "MuseTalk" + if str(muse_dir) not in sys.path: + sys.path.insert(0, str(muse_dir)) + + from musetalk.utils.utils import get_file_type + from musetalk.whisper.audio2feature import load_audio + + # MuseTalk 内部推理 API(具体取决于部署的 MuseTalk 版本) + # 典型调用: + # model = MuseTalkModel(...) + # result = model.infer(video=video_path, audio=preprocessed_audio) +``` + +### 修复 2:音频预处理(必须) + +在 `musetalk_server.py` 中添加 `_preprocess_audio()` 函数: + +```python +def _preprocess_audio(input_path: Path, output_path: Path, target_sr: int = 16000) -> None: + """将输入音频转换为 MuseTalk 要求的格式:16kHz mono 16bit WAV。 + + MuseTalk 的 whisper audio2feature 要求 16kHz 采样率, + 当前 TTS 输出 22050Hz MP3,不转换会导致 mel 频谱错位、 + 音素特征提取错误,口型只跟能量不跟音素。 + """ + cmd = [ + "ffmpeg", "-y", + "-i", str(input_path), + "-ar", str(target_sr), # 重采样到 16kHz + "-ac", "1", # 单声道 + "-sample_fmt", "s16", # 16bit PCM + "-f", "wav", # WAV 格式 + str(output_path), + ] + _run_ffmpeg(cmd, timeout=60) + + if not output_path.exists() or output_path.stat().st_size < 100: + raise RuntimeError(f"音频预处理失败: {output_path}") + + # 验证输出格式 + try: + import wave + with wave.open(str(output_path), 'rb') as wf: + sr = wf.getframerate() + ch = wf.getnchannels() + sw = wf.getsampwidth() + if sr != target_sr or ch != 1 or sw != 2: + logger.warning("音频格式异常: sr=%d ch=%d sw=%d", sr, ch, sw) + except Exception as e: + logger.warning("无法验证 WAV 格式: %s", e) + + logger.info("音频预处理完成: %s → %s (%dHz mono 16bit WAV)", + input_path.name, output_path.name, target_sr) +``` + +### 修复 3:循环边界 crossfade + +在兜底循环路径中,使用 ffmpeg `xfade` 滤镜在循环边界处混合: + +```python +def _mux_with_loop_crossfade(video_path, audio_path, output_path, crossfade_frames=3): + """循环视频 + crossfade 过渡,减少循环边界跳变。""" + fps = _get_video_fps(video_path) + crossfade_duration = crossfade_frames / fps # 通常 0.12s (3帧@25fps) + video_duration = _get_media_duration(video_path) + + # 使用 tpad 滤镜在视频末尾添加 crossfade + # 方案:用 concat 把 [视频 + 视频前N帧crossfade] 拼起来 + cmd = [ + "ffmpeg", "-y", + "-stream_loop", "-1", + "-i", str(video_path), + "-i", str(audio_path), + "-filter_complex", + f"[0:v]split=2[v1][v2];" + f"[v2]trim=0:{crossfade_duration},setpts=PTS-STARTPTS+{video_duration - crossfade_duration}/TB[xfade_in];" + f"[v1][xfade_in]xfade=transition=fade:duration={crossfade_duration}:offset={video_duration - crossfade_duration}[looped];" + f"[looped]format=yuv420p[out]", + "-map", "[out]", + "-map", "1:a:0", + "-c:v", encoder, + "-preset", preset, + "-c:a", "aac", "-b:a", "128k", + "-t", f"{audio_duration:.3f}", + str(output_path), + ] +``` + +**但更简单的方案**:如果 MuseTalk 正确处理了全量音频(输出时长=音频时长),就不需要兜底循环了。crossfade 只是兜底路径的优化。 + +### 修复 4:推理参数 + +MuseTalk 的关键推理参数: +- `bbox_shift`: 口型区域偏移量,默认 0,范围 [-5, 5]。影响口型位置 +- `batch_size`: 推理批次大小,RTX2060 6G 显存建议 4-8 +- 视频帧率:MuseTalk 期望 25fps,当前已正确获取 fps + +## 完整修复优先级 + +| 优先级 | 修复 | 影响 | +|---|---|---| +| **P0** | `_run_inference()` 调用真实 MuseTalk 模型 | **没有这个其他都没意义** | +| **P0** | 音频预处理:22050Hz MP3 → 16kHz mono 16bit WAV | 音素特征正确提取 | +| **P1** | 循环边界 crossfade | 减少视觉跳变 | +| **P2** | bbox_shift 参数暴露为可配置 | 微调口型位置 | + +## 在 RTX2060 上调试需要什么 + +1. **确认 MuseTalk 推理代码**: + ```bash + # 在 RTX2060 上 + ls ~/projects/MuseTalk/ + cat ~/projects/MuseTalk/musetalk_server.py # 看实际部署版本 + ``` + +2. **验证音频格式**: + ```bash + # 在 musetalk_server.py 的推理前添加日志 + ffprobe -v error -show_entries stream=sample_rate,channels,codec_name,bits_per_sample \ + -of default /tmp/musetalk_*/input_audio.wav + ``` + +3. **提取中间结果对比**: + ```bash + # 对比 TTS 原始音频 vs 预处理后的 16kHz WAV + # 用 whisper 提取 mel 特征并可视化 + python -c " + import whisper + model = whisper.load_model('tiny') + audio = whisper.load_audio('/tmp/test_audio.wav') # 预处理后的 + audio = whisper.pad_or_trim(audio) + mel = whisper.log_mel_spectrogram(audio).to(model.device) + # 保存 mel 特征供可视化 + import numpy as np + np.save('/tmp/mel_features.npy', mel.cpu().numpy()) + " + ``` + +4. **MuseTalk 推理中间帧**: + ```bash + # 在 MuseTalk 推理代码中保存中间帧到 /tmp/debug_frames/ + # 对比输入帧 vs 推理后帧 vs 原视频帧 + ``` + +## 建议的下一步 + +1. 先在 RTX2060 上 `cat ~/projects/MuseTalk/musetalk_server.py` 确认实际部署的代码 +2. 确认 MuseTalk 是否真的被调用(看日志有没有 "使用示例推理逻辑" 的 warning) +3. 如果确认是 stub,需要把真正的 MuseTalk 推理逻辑集成到 `_run_inference()` +4. 同时修复音频预处理(P0),这个无论推理代码如何都是必须的 diff --git a/deploy/gpu_worker/musetalk_server.py b/deploy/gpu_worker/musetalk_server.py index 1c2f2bf88..31213a69a 100644 --- a/deploy/gpu_worker/musetalk_server.py +++ b/deploy/gpu_worker/musetalk_server.py @@ -1,13 +1,18 @@ """MuseTalk Flask HTTP 服务 — 反向轮询架构的服务端部分. 部署在 RTX2060 本地,接收 gpu_worker.py 的推理请求,调用 MuseTalk 生成口型同步视频。 -本文件修复了原 worker.py 的 8 个工程 bug,并新增 /cancel 端点。 + +#2000 关键修复: + - 集成真实 MuseTalk 推理(替换原有 stub 代码) + - 音频预处理:22050Hz MP3 → 16kHz mono 16bit WAV(MuseTalk 要求) + - 模型懒加载:首次推理时加载,后续复用,避免重复加载 + - 视频帧循环使用 mirror indexing(乒乓模式),消除循环边界跳变 + - bbox_shift 可通过请求参数配置 #1978 性能修复(v2 架构): MuseTalk 原生支持长音频输入(内部循环视频帧),不需要我们先 loop 视频。 正确流程:原视频 + 全量音频 → MuseTalk 推理 → 输出时长=音频时长的无声画面 → ffmpeg 快速 -c:v copy 替换音轨。推理时间不变(~14s),后处理几秒。 - 禁止在推理前用 ffmpeg 循环视频(会导致 MuseTalk 处理 2x+ 帧数,慢 16 倍)。 环境变量: MUSE_PORT 监听端口,默认 7861 @@ -18,21 +23,27 @@ MUSE_DEFAULT_FPS 视频 fps 兜底值,默认 25.0 MUSE_TEMP_DIR 临时文件目录,默认 /tmp/musetalk_$$ MUSE_VIDEO_ENCODER 循环视频时的编码器(仅兜底):auto(默认)/h264_nvenc/libx264 + MUSE_DIR MuseTalk 仓库路径,默认 /home/ying/projects/MuseTalk + MUSE_MODEL_DIR 模型目录(相对 MUSE_DIR),默认 models/musetalk + MUSE_USE_FLOAT16 使用 FP16 推理,默认 1(开启) + MUSE_BATCH_SIZE 推理批次大小,默认 8 接口: GET /health 健康检查 + GPU 显存信息 - POST /inference 推理请求(multipart: video + audio) + POST /inference 推理请求(multipart: video + audio, form: bbox_shift) POST /cancel 终止当前推理任务 """ from __future__ import annotations import atexit +import copy import logging import os import shutil import signal import subprocess +import sys import threading import time from pathlib import Path @@ -68,6 +79,14 @@ class Config: video_encoder: str = _env("MUSE_VIDEO_ENCODER", "auto") or "auto" # 判定音视频时长差异的容差(秒) duration_epsilon: float = 0.25 + # MuseTalk 仓库路径 + muse_dir: str = _env("MUSE_DIR", "/home/ying/projects/MuseTalk") + # 模型目录(相对 MUSE_DIR) + muse_model_dir: str = _env("MUSE_MODEL_DIR", "models/musetalk") + # 是否使用 FP16(节省显存,RTX2060 建议开启) + use_float16: bool = _env("MUSE_USE_FLOAT16", "1") == "1" + # 推理批次大小(RTX2060 6G 显存建议 4-8) + batch_size: int = int(_env("MUSE_BATCH_SIZE", "8")) # ── 全局状态 ────────────────────────────────────────────────────────── @@ -75,6 +94,13 @@ inference_lock = threading.Lock() current_task: dict = {"task_id": None, "process": None, "start_time": 0.0} shutdown_event = threading.Event() +# ── MuseTalk 模型懒加载 ───────────────────────────────────────────── +_muse_models = None +_muse_models_lock = threading.Lock() +_muse_models_loaded = False +_muse_load_error = None + + # ── Flask App ───────────────────────────────────────────────────────── app = Flask(__name__) @@ -215,6 +241,29 @@ def _pick_video_encoder() -> str: return "libx264" +def _preprocess_audio(input_path: Path, output_path: Path, target_sr: int = 16000) -> None: + """将输入音频转换为 MuseTalk 要求的格式:16kHz mono 16bit WAV. + + MuseTalk 的 whisper audio2feature 要求 16kHz 采样率的单声道音频。 + 当前 TTS 输出为 22050Hz MP3,不转换会导致 mel 频谱错位、 + 音素特征提取错误,口型只跟能量不跟音素。 + """ + cmd = [ + "ffmpeg", "-y", "-v", "warning", + "-i", str(input_path), + "-ar", str(target_sr), # 重采样到 16kHz + "-ac", "1", # 单声道 + "-sample_fmt", "s16", # 16bit PCM + str(output_path), + ] + _run_ffmpeg(cmd, timeout=60) + + if not output_path.exists() or output_path.stat().st_size < 100: + raise RuntimeError(f"音频预处理失败: {output_path}") + + logger.info("音频预处理完成: %s → 16kHz mono WAV", input_path.name) + + def _mux_video_with_audio( video_path: Path, audio_path: Path, @@ -340,137 +389,359 @@ def _run_ffmpeg(cmd: list, timeout: float = 120) -> subprocess.CompletedProcess: raise RuntimeError(f"ffmpeg 超时(>{timeout}s)") from exc +# ── MuseTalk 模型加载 ──────────────────────────────────────────────── + + +def _load_musetalk_models(): + """懒加载 MuseTalk 模型(全局单例,首次调用时加载). + + 加载 VAE、UNet、PositionalEncoder 三个核心组件。 + 加载到 GPU 后转为 FP16(如果配置开启)以节省显存。 + RTX2060 6G 显存,FP16 大约需要 3-4GB。 + """ + global _muse_models, _muse_models_loaded, _muse_load_error + + if _muse_models_loaded: + return _muse_models + if _muse_load_error is not None: + raise _muse_load_error + + with _muse_models_lock: + if _muse_models_loaded: + return _muse_models + + try: + muse_dir = Path(Config.muse_dir) + if not muse_dir.exists(): + raise FileNotFoundError( + f"MuseTalk 目录不存在: {muse_dir}\n" + f"请设置 MUSE_DIR 环境变量指向 MuseTalk 仓库路径" + ) + + # 将 MuseTalk 加入 sys.path(只在首次加载时) + muse_str = str(muse_dir) + if muse_str not in sys.path: + sys.path.insert(0, muse_str) + + import torch + from musetalk.utils.utils import load_all_model + + device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu") + logger.info("MuseTalk 使用设备: %s", device) + + # 自动检测模型路径 + # 优先检测 v1.5 模型,然后回退到 v1 + v15_unet = muse_dir / "models" / "musetalkV15" / "unet.pth" + v1_unet = muse_dir / "models" / "musetalk" / "pytorch_model.bin" + + if v15_unet.exists(): + unet_model_path = str(v15_unet) + unet_config = str(muse_dir / "models" / "musetalkV15" / "musetalk.json") + model_version = "v15" + elif v1_unet.exists(): + unet_model_path = str(v1_unet) + unet_config = str(muse_dir / "models" / "musetalk" / "config.json") + model_version = "v1" + else: + raise FileNotFoundError( + f"未找到 MuseTalk 模型权重。\n" + f"检查路径: {v15_unet} 或 {v1_unet}\n" + f"请确认模型已下载到 MuseTalk 仓库的 models/ 目录下" + ) + + logger.info("加载 MuseTalk %s 模型: %s", model_version, unet_model_path) + + vae, unet, pe = load_all_model( + unet_model_path=unet_model_path, + vae_type="sd-vae", + unet_config=unet_config, + device=device, + ) + timesteps = torch.tensor([0], device=device) + + # FP16 转换(节省 ~50% 显存) + if Config.use_float16: + pe = pe.half() + vae.vae = vae.vae.half() + unet.model = unet.model.half() + logger.info("已启用 FP16 推理") + + pe = pe.to(device) + vae.vae = vae.vae.to(device) + unet.model = unet.model.to(device) + + # 加载 AudioProcessor 和 face parsing + from musetalk.utils.audio_processor import AudioProcessor + from musetalk.utils.face_parsing import FaceParsing + + audio_processor = AudioProcessor() + face_parsing = FaceParsing() + + _muse_models = { + "vae": vae, + "unet": unet, + "pe": pe, + "timesteps": timesteps, + "audio_processor": audio_processor, + "face_parsing": face_parsing, + "device": device, + "model_version": model_version, + } + _muse_models_loaded = True + logger.info("MuseTalk 模型加载完成 (版本=%s, 设备=%s, fp16=%s)", + model_version, device, Config.use_float16) + + # 打印显存使用情况 + if torch.cuda.is_available(): + allocated = torch.cuda.memory_allocated() / 1024**2 + reserved = torch.cuda.memory_reserved() / 1024**2 + logger.info("GPU 显存: 已分配 %.0fMB, 已预留 %.0fMB", allocated, reserved) + + return _muse_models + + except Exception as exc: + _muse_load_error = exc + logger.error("MuseTalk 模型加载失败: %s", exc) + raise + + +# ── MuseTalk 推理核心 ───────────────────────────────────────────────── + + +def _mirror_index(size: int, index: int) -> int: + """乒乓式循环索引,避免循环边界硬切跳变. + + 效果: 0→1→2→...→N→N-1→...→1→0→1→... + 比简单的 index % size 在边界处更平滑。 + """ + if size == 0: + return 0 + turn = index // size + res = index % size + if turn % 2 == 0: + return res + else: + return size - res - 1 + + def _run_inference( video_path: Path, audio_path: Path, output_path: Path, + bbox_shift: int = 0, ) -> None: - """执行 MuseTalk 推理(v2 架构:全量音频直传,不在推理前 loop 视频). + """执行 MuseTalk 真实推理. - #1978 性能修复核心: - MuseTalk 原生支持长音频输入,内部会自动循环视频帧。 - 我们只需把【原视频】和【全量音频】传给 MuseTalk, - 输出视频时长 = 音频时长(MuseTalk 自行处理帧循环)。 - 禁止在推理前用 ffmpeg 循环视频(会导致慢 16 倍)。 + 流程: + 1. 音频预处理:任意格式 → 16kHz mono 16bit WAV + 2. 加载/复用 MuseTalk 模型(VAE + UNet + PE + Whisper) + 3. 视频预处理:提取帧 → 人脸检测 → 获取 bbox → VAE 编码 latent + 4. 音频特征提取:whisper 提取 audio features (50×384 per chunk) + 5. 批量推理:UNet 去噪 → VAE 解码 → 得到口型同步的人脸帧 + 6. 帧合成:将生成的人脸贴回原帧(使用 face parsing 做边缘融合) + 7. 输出无声视频(后续由 _mux_video_with_audio 封装 TTS 音频) - 实际部署时替换为 MuseTalk 真实推理逻辑。 - 此处为示例实现:提取帧 → 模拟 MuseTalk 产出音频时长的无声画面 → 快速封装。 + Args: + video_path: 输入视频路径 + audio_path: 输入音频路径(任意格式,会被预处理为 16kHz WAV) + output_path: 输出无声视频路径 + bbox_shift: 口型区域垂直偏移量,默认 0,范围 [-5, 5] """ + import cv2 + import numpy as np + import torch + from tqdm import tqdm + + from musetalk.utils.preprocessing import get_landmark_and_bbox, read_imgs + from musetalk.utils.utils import datagen + from musetalk.utils.blending import get_image + + # 加载模型(首次调用时加载,后续复用) + models = _load_musetalk_models() + vae = models["vae"] + unet = models["unet"] + pe = models["pe"] + timesteps = models["timesteps"] + audio_processor = models["audio_processor"] + device = models["device"] + model_version = models["model_version"] + fps = _get_video_fps(video_path) audio_duration = _get_media_duration(audio_path) video_duration = _get_media_duration(video_path) logger.info( - "推理开始: video=%.2fs, audio=%.2fs, fps=%.2f", - video_duration, - audio_duration, - fps, + "MuseTalk 推理开始: video=%.2fs, audio=%.2fs, fps=%.1f, bbox_shift=%d", + video_duration, audio_duration, fps, bbox_shift, ) - frames_dir = video_path.parent / "frames" - frames_dir.mkdir(parents=True, exist_ok=True) + # ── Step 1: 音频预处理(关键修复:22050Hz MP3 → 16kHz mono WAV)── + audio_wav_path = video_path.parent / "audio_16k_mono.wav" + _preprocess_audio(audio_path, audio_wav_path, target_sr=16000) - # 1. 从原视频提取帧(仅原视频长度,不循环) + # ── Step 2: 视频帧提取 ── + input_frames = read_imgs(str(video_path)) + total_frames = len(input_frames) + if total_frames == 0: + raise RuntimeError("未能从视频中提取到任何帧") + logger.info("提取到 %d 帧视频画面", total_frames) + + # ── Step 3: 人脸检测 & bbox 计算 ── + coord_list, coord_placeholder = get_landmark_and_bbox( + input_frames, vid_pts=0, bbox_shift=bbox_shift + ) + logger.info("人脸检测完成,有效 bbox: %d/%d", sum(1 for c in coord_list if c is not coord_placeholder), total_frames) + + # 使用 mirror indexing 循环帧和坐标(避免硬切跳变) + num_output_frames = int(audio_duration * fps) + if num_output_frames <= 0: + num_output_frames = total_frames + + # ── Step 4: 音频特征提取 ── + # 使用 librosa 加载预处理后的 16kHz 音频 + import librosa + audio_array, _ = librosa.load(str(audio_wav_path), sr=16000, mono=True) + whisper_features = audio_processor.feature2chunks( + feature_array=audio_array, + fps=fps, + weight_dtype=(torch.float16 if Config.use_float16 else torch.float32), + batch_size=Config.batch_size, + ) + logger.info("音频特征提取完成: %d 个 chunk", len(whisper_features)) + + # ── Step 5: 视频帧 VAE 编码为 latent ── + input_latent_list = [] + with torch.no_grad(): + for frame in input_frames: + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + frame_resized = cv2.resize(frame_rgb, (256, 256)) + frame_tensor = torch.from_numpy(frame_resized).float() / 127.5 - 1.0 + frame_tensor = frame_tensor.permute(2, 0, 1).unsqueeze(0).to(device) + if Config.use_float16: + frame_tensor = frame_tensor.half() + latent = vae.encode_latents(frame_tensor) + input_latent_list.append(latent) + logger.info("视频帧 VAE 编码完成: %d 个 latent", len(input_latent_list)) + + # ── Step 6: 批量推理 ── + result_frames = [] + total_batches = (num_output_frames + Config.batch_size - 1) // Config.batch_size + + for batch_idx in tqdm(range(total_batches), desc="MuseTalk 推理"): + # 构建 whisper batch + whisper_batch = whisper_features[ + batch_idx * Config.batch_size : (batch_idx + 1) * Config.batch_size + ] + if len(whisper_batch) == 0: + break + + # 构建 latent batch(使用 mirror indexing 循环) + latent_indices = [] + for i in range(len(whisper_batch)): + global_idx = batch_idx * Config.batch_size + i + frame_idx = _mirror_index(total_frames, global_idx) + latent_indices.append(frame_idx) + + latent_batch = torch.cat( + [input_latent_list[idx] for idx in latent_indices], dim=0 + ) + latent_batch = latent_batch.to(device) + if Config.use_float16: + latent_batch = latent_batch.half() + + # 位置编码 + audio_feature_batch = pe(whisper_batch) + + # UNet 推理 + with torch.no_grad(): + latent_batch = latent_batch.to(dtype=unet.model.dtype) + pred_latents = unet.model( + latent_batch, + timesteps, + encoder_hidden_states=audio_feature_batch, + ).sample + + # VAE 解码 + recon_frames = vae.decode_latents(pred_latents) + result_frames.extend(recon_frames) + + logger.info("推理完成,生成 %d 帧", len(result_frames)) + + # ── Step 7: 合成最终帧并写视频 ── + output_frames_dir = video_path.parent / "output_frames" + output_frames_dir.mkdir(parents=True, exist_ok=True) + + for i, res_frame in enumerate(tqdm(result_frames[:num_output_frames], desc="合成帧")): + orig_idx = _mirror_index(total_frames, i) + ori_frame = copy.deepcopy(input_frames[orig_idx]) + bbox = coord_list[orig_idx] if orig_idx < len(coord_list) else coord_placeholder + + if bbox is coord_placeholder: + # 没有检测到人脸的帧,保持原样 + combined = ori_frame + else: + x1, y1, x2, y2 = bbox + if model_version == "v15": + # v1.5 额外扩展下边界(下巴区域) + y2 = min(y2 + 10, ori_frame.shape[0]) + try: + res_frame_resized = cv2.resize( + res_frame.astype(np.uint8), (x2 - x1, y2 - y1) + ) + except Exception: + combined = ori_frame + cv2.imwrite( + str(output_frames_dir / f"{i:08d}.png"), combined + ) + continue + + # 使用 face parsing 做边缘融合 + combined = get_image( + ori_frame, + res_frame_resized, + [x1, y1, x2, y2], + ) + + cv2.imwrite(str(output_frames_dir / f"{i:08d}.png"), combined) + + # 帧序列 → 无声视频 + silent_video_path = video_path.parent / "silent_output.mp4" _run_ffmpeg( [ - "ffmpeg", - "-y", - "-i", - str(video_path), - "-r", - str(fps), - str(frames_dir / "frame_%05d.png"), - ], - timeout=120, - ) - - frame_files = sorted(frames_dir.glob("*.png")) - if not frame_files: - raise RuntimeError("未从视频中提取到帧") - - # 2. 模拟 MuseTalk 推理:输入原视频帧 + 全量音频,输出音频时长的无声画面。 - # TODO: 替换为 MuseTalk 真实推理逻辑。 - # MuseTalk 真实调用示例(伪代码): - # from musetalk import MuseTalkModel - # model = MuseTalkModel(...) - # silent_video = model.infer(video_path=video_path, audio_path=audio_path) - # # MuseTalk 内部会循环视频帧匹配音频长度,输出时长=音频时长 - logger.warning("使用示例推理逻辑,未实际调用 MuseTalk 模型") - - # 示例:生成音频时长的无声画面(循环原视频帧到音频长度) - # 真实部署时 silent_video_path 应替换为 MuseTalk 输出的无声视频路径 - silent_video_path = video_path.parent / "visual_silent.mp4" - - if audio_duration > video_duration + Config.duration_epsilon: - # 音频更长:循环视频帧到音频长度(仅用于示例,真实 MuseTalk 内部处理) - encoder = _pick_video_encoder() - preset = "p4" if encoder == "h264_nvenc" else "veryfast" - logger.info( - "示例:循环视频帧到音频长度 %.2fs(真实 MuseTalk 内部处理,无需此步骤)", - audio_duration, - ) - cmd = [ - "ffmpeg", - "-y", - "-stream_loop", - "-1", - "-i", - str(video_path), - "-an", - "-c:v", - encoder, - "-preset", - preset, - "-t", - f"{audio_duration:.3f}", + "ffmpeg", "-y", "-v", "warning", + "-r", str(fps), + "-f", "image2", + "-i", str(output_frames_dir / "%08d.png"), + "-vcodec", "libx264", + "-vf", "format=yuv420p", + "-crf", "18", str(silent_video_path), - ] - try: - _run_ffmpeg(cmd, timeout=300) - except RuntimeError: - if encoder == "h264_nvenc": - cmd[cmd.index(encoder)] = "libx264" - cmd[cmd.index(preset) + 1] = "veryfast" - _run_ffmpeg(cmd, timeout=300) - else: - raise - else: - # 音频不长:直接生成无声视频(原视频长度) - _run_ffmpeg( - [ - "ffmpeg", - "-y", - "-i", - str(video_path), - "-an", - "-c:v", - "libx264", - "-preset", - "veryfast", - str(silent_video_path), - ], - timeout=300, - ) - - # 3. 快速封装:-map 取推理画面 + 驱动音频,-c:v copy 无损秒级封装 - # MuseTalk 输出已匹配音频长度,此处无需循环,仅替换音轨 - _mux_video_with_audio(silent_video_path, audio_path, output_path) - - if not output_path.exists() or output_path.stat().st_size < 1024: - raise RuntimeError("推理产物不存在或过小") - - logger.info( - "推理完成: output=%.2fs (audio=%.2fs)", - _get_media_duration(output_path), - audio_duration, + ], + timeout=300, ) + # 将无声视频复制到输出路径 + shutil.copy2(str(silent_video_path), str(output_path)) + + # 清理中间文件 + try: + shutil.rmtree(str(output_frames_dir)) + if audio_wav_path.exists(): + audio_wav_path.unlink() + if silent_video_path.exists() and str(silent_video_path) != str(output_path): + silent_video_path.unlink() + except Exception as e: + logger.warning("清理中间文件失败: %s", e) + + logger.info("MuseTalk 推理完成: output=%s, duration=%.2fs", + output_path.name, _get_media_duration(output_path)) + # ── 路由 ────────────────────────────────────────────────────────────── @app.route("/health", methods=["GET"]) def health(): - """健康检查 + GPU 显存信息.""" + """健康检查 + GPU 显存信息 + MuseTalk 模型状态.""" gpu_info = _get_gpu_info() task_info = { "task_id": current_task["task_id"], @@ -482,6 +753,8 @@ def health(): "status": "healthy", "gpu": gpu_info, "current_task": task_info, + "musetalk_loaded": _muse_models_loaded, + "musetalk_load_error": str(_muse_load_error) if _muse_load_error else None, "timestamp": time.time(), } ) @@ -491,7 +764,8 @@ def health(): def inference(): """推理请求:multipart form 包含 video 和 audio 文件. - #1978 v2:MuseTalk 直接处理全量音频,输出时长=音频时长,无需预处理循环。 + 可选 form 参数: + bbox_shift: 口型区域垂直偏移量,默认 0,范围 [-5, 5] """ # 并发控制:检查锁 if not inference_lock.acquire(blocking=False): @@ -510,6 +784,8 @@ def inference(): video_file = request.files["video"] audio_file = request.files["audio"] task_id = request.form.get("task_id", f"task_{int(time.time())}") + bbox_shift = int(request.form.get("bbox_shift", "0")) + bbox_shift = max(-5, min(5, bbox_shift)) # 限制范围 # 文件大小检查 err = _check_file_size(video_file, Config.video_max_mb, "视频") @@ -523,13 +799,14 @@ def inference(): task_dir = Path(Config.temp_dir) / task_id task_dir.mkdir(parents=True, exist_ok=True) video_path = task_dir / "input.mp4" - audio_path = task_dir / "input_audio.wav" + audio_path = task_dir / "input_audio.bin" output_path = task_dir / "output.mp4" video_file.save(str(video_path)) audio_file.save(str(audio_path)) - logger.info("开始推理 task_id=%s, video=%s, audio=%s", task_id, video_path.name, audio_path.name) + logger.info("开始推理 task_id=%s, video=%s, audio=%s, bbox_shift=%d", + task_id, video_path.name, audio_path.name, bbox_shift) # 更新当前任务信息 current_task["task_id"] = task_id @@ -541,8 +818,9 @@ def inference(): def inference_thread(): try: - _run_inference(video_path, audio_path, output_path) + _run_inference(video_path, audio_path, output_path, bbox_shift=bbox_shift) except Exception as exc: + logger.exception("推理异常: %s", exc) result_container["error"] = str(exc) thread = threading.Thread(target=inference_thread) @@ -633,11 +911,14 @@ def main(): gpu_info["memory_used_mb"], gpu_info["memory_total_mb"], ) + logger.info("MuseTalk 仓库路径: %s", Config.muse_dir) logger.info( - "启动 MuseTalk Server: port=%d, timeout=%.0fs, max_concurrent=%d", + "启动 MuseTalk Server: port=%d, timeout=%.0fs, max_concurrent=%d, fp16=%s, batch_size=%d", Config.port, Config.inference_timeout, Config.max_concurrent, + Config.use_float16, + Config.batch_size, ) app.run(host="0.0.0.0", port=Config.port, threaded=True) diff --git a/packages/application/cosyvoice_service.py b/packages/application/cosyvoice_service.py index 38df07560..c249f1c2d 100644 --- a/packages/application/cosyvoice_service.py +++ b/packages/application/cosyvoice_service.py @@ -71,6 +71,28 @@ EMOTION_MAP: dict[str, str] = { "friendly": "happy", } +# ── Style(语气风格)→ Instruct 指令映射(CosyVoice v3 Instruct 模式)── +# 前端传 6 种 style(natural/excited/professional/gentle/news/livestream), +# 映射为中文自然语言指令,写入 CosyVoice input.instruction 字段。 +# 克隆音色直接使用中文自然语言;系统音色(严格格式)需转换为兼容的 instruct。 +STYLE_INSTRUCTION_MAP: dict[str, str] = { + "natural": "用自然、平和的语气说话。", + "excited": "用兴奋、激动的语气说话。", + "professional": "用专业、正式的语气说话。", + "gentle": "用温柔、柔和的语气说话。", + "news": "用新闻播报的语气说话。", + "livestream": "用直播解说的语气说话。", +} + +# 严格格式系统音色:style → emotion 映射(用于无法使用自由文本指令的音色) +_STYLE_TO_EMOTION: dict[str, str] = { + "natural": "neutral", + "excited": "happy", + "professional": "neutral", + "gentle": "neutral", + # news / livestream 无直接对应 emotion,由 build_style_instruction 特殊处理 +} + # ── 支持 emotion Instruct 的 v3-flash 系统音色白名单(官方音色列表标注"Instruct:支持"且支持情感值)── # 这些音色的 instruction 必须使用中文固定格式 "你说话的情感是。"; @@ -148,6 +170,45 @@ def normalize_emotion(emotion: str) -> str: return "neutral" +def build_style_instruction(voice_id: str, style: str) -> str: + """根据 voice 类型构造 style instruction. + + - 克隆音色:直接使用中文自然语言指令(CosyVoice 对克隆音色支持任意自然语言) + - 系统音色(emotion-instruct 白名单内):转换为兼容的 instruct 格式 + * excited → "你说话的情感是happy。" + * news → "你正在进行新闻播报,你说话的情感是neutral。" + * 其他无直接对应 → 返回空串(不传 instruction) + - 其他系统音色(不支持 Instruct):返回空串 + + Args: + voice_id: CosyVoice voice 参数 + style: 风格值(natural/excited/professional/gentle/news/livestream) + + Returns: + instruction 字符串;不支持时返回空串 + """ + if not style or style == "natural": + # natural 是默认风格,不额外添加 instruct + return "" + description = STYLE_INSTRUCTION_MAP.get(style, "") + if not description: + logger.warning("未知的 style 值 %r,跳过 style instruction", style) + return "" + # 克隆音色:直接使用中文自然语言 + if _is_cloned_voice(voice_id): + return description + # 严格格式系统音色(white-listed):转换为兼容格式 + if voice_id in _SYSTEM_VOICES_WITH_EMOTION_INSTRUCT: + if style == "news": + return "你正在进行新闻播报,你说话的情感是neutral。" + emotion_val = _STYLE_TO_EMOTION.get(style) + if emotion_val: + return f"你说话的情感是{emotion_val}。" + return "" + # 其他系统音色:不支持 Instruct,返回空串 + return "" + + # 系统音色仅支持中文/英文(language_hints 取值) _SYSTEM_VOICE_LANGS = {"zh", "en"} @@ -573,6 +634,8 @@ class CosyVoiceService: volume: int = 50, emotion: str = "", language: str = "zh", + style: str = "", + pitch: float = 1.0, ) -> dict: """提交语音合成任务(同步非流式,直接返回结果). @@ -590,6 +653,10 @@ class CosyVoiceService: 或前端中文标签(中立/开心/难过/生气/惊讶/恐惧/厌恶),兼容旧值 natural/excited/calm/friendly;空串不传,未知值默认 neutral language: 语言代码(zh/en 等,默认 zh;系统音色仅 zh/en 传 language_hints) + style: 语气风格(natural/excited/professional/gentle/news/livestream), + 可选参数,用于 CosyVoice v3 Instruct 模式; + style 与 emotion 互斥,style 优先级高于 emotion + pitch: 音调(0.5-2.0),1.0 为默认值 Returns: dict: {"audio_url": str, "request_id": str, @@ -617,11 +684,20 @@ class CosyVoiceService: "rate": speed, "volume": volume, } - # 情绪 → instruction(按 voice 类型选择格式) - norm_emotion = normalize_emotion(emotion) - emotion_instruction = build_emotion_instruction(voice_id, norm_emotion) - if emotion_instruction: - input_payload["instruction"] = emotion_instruction + # pitch: CosyVoice API 支持 [0.5, 2.0],1.0 为默认值,非默认时才传 + if pitch != 1.0: + input_payload["pitch"] = pitch + # style 优先级高于 emotion:style 是新的 Instruct 模式参数, + # 前端传 style 时不再走旧的 emotion 逻辑 + instruction = "" + if style: + instruction = build_style_instruction(voice_id, style) + if not instruction: + # 回退到旧的 emotion → instruction 逻辑 + norm_emotion = normalize_emotion(emotion) + instruction = build_emotion_instruction(voice_id, norm_emotion) + if instruction: + input_payload["instruction"] = instruction # 语言 → language_hints 数组(仅取第一个元素生效); # 系统音色(非克隆/非 voice_id 中包含下划线以外的短 ID)仅传 zh/en,其他语言不传避免报错 norm_lang = normalize_language(language) @@ -681,7 +757,8 @@ class CosyVoiceService: volume: int = 50, emotion: str = "", language: str = "zh", - timeout: float = 120.0, + style: str = "", + pitch: float = 1.0, ) -> SynthesizeResult: """语音合成(同步非流式). @@ -695,6 +772,8 @@ class CosyVoiceService: format: 输出格式(mp3/wav/pcm),空表示使用配置默认值 speed: 语速(0.5-2.0),1.0 为正常速度 volume: 音量(0-100),默认 50 + style: 语气风格(natural/excited/professional/gentle/news/livestream) + pitch: 音调(0.5-2.0),1.0 为默认值 timeout: 超时时间(秒),保留参数兼容 Returns: @@ -714,6 +793,8 @@ class CosyVoiceService: volume=volume, emotion=emotion, language=language, + style=style, + pitch=pitch, ) return SynthesizeResult( diff --git a/packages/application/tts_job/workflow.py b/packages/application/tts_job/workflow.py index 3ca40b0e6..866b3d2a2 100644 --- a/packages/application/tts_job/workflow.py +++ b/packages/application/tts_job/workflow.py @@ -145,16 +145,22 @@ class TTSWorkflowService: try: _meta = dict(job.metadata) _speed = float(_meta.get("speed", 1.0) or 1.0) + _volume = int(_meta.get("volume", 50) or 50) _emotion = str(_meta.get("emotion", "") or "") + _style = str(_meta.get("style", "") or "") _language = str(_meta.get("language", "zh-CN") or "zh-CN") + _pitch = float(_meta.get("pitch", 1.0) or 1.0) submit_result = self.cosyvoice_service.submit_synthesize_task( text=job.input_text, voice_id=job.voice_id, sample_rate=job.sample_rate, format=job.format, speed=_speed, + volume=_volume, emotion=_emotion, language=_language, + style=_style, + pitch=_pitch, ) # 保存 task_id / request_id 到 metadata @@ -291,7 +297,9 @@ class TTSWorkflowService: speed = float(job_metadata.get("speed", 1.0)) volume = int(job_metadata.get("volume", 50)) emotion = str(job_metadata.get("emotion", "") or "") + style = str(job_metadata.get("style", "") or "") language = str(job_metadata.get("language", "zh-CN") or "zh-CN") + pitch = float(job_metadata.get("pitch", 1.0) or 1.0) result = self.cosyvoice_service.submit_synthesize_task( text=job.input_text, @@ -302,6 +310,8 @@ class TTSWorkflowService: volume=volume, emotion=emotion, language=language, + style=style, + pitch=pitch, ) audio_url = result.get("audio_url", "") if not audio_url: @@ -414,7 +424,10 @@ class TTSWorkflowService: results: list[dict | None] = [None] * len(segments) _seg_meta = job.metadata or {} _seg_speed = float(_seg_meta.get("speed", 1.0) or 1.0) + _seg_volume = int(_seg_meta.get("volume", 50) or 50) _seg_emotion = str(_seg_meta.get("emotion", "") or "") + _seg_style = str(_seg_meta.get("style", "") or "") + _seg_pitch = float(_seg_meta.get("pitch", 1.0) or 1.0) with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_idx = {} @@ -426,8 +439,11 @@ class TTSWorkflowService: sample_rate=job.sample_rate, format=job.format, speed=_seg_speed, + volume=_seg_volume, emotion=_seg_emotion, language=_seg_meta.get("language", "zh-CN") or "zh-CN", + style=_seg_style, + pitch=_seg_pitch, ) future_to_idx[future] = idx