Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia 8ebccd61d9 fix: 集成真实MuseTalk推理 + TTS style/speed/volume参数全链路修复
MuseTalk推理修复(P0):
- 替换_run_inference() stub代码为真实MuseTalk推理逻辑
- 新增音频预处理:任意格式→16kHz mono 16bit WAV
- 模型懒加载(VAE+UNet+PE+Whisper),首次推理后复用
- 使用mirror indexing循环帧,消除视频循环边界跳变
- bbox_shift参数可通过请求配置(范围-5~5)
- FP16推理支持,节省RTX2060显存
- /health接口增加模型加载状态信息

TTS参数透传修复(P0):
- schemas新增style字段(TTSSynthesizeRequest/TTSPreviewRequest/CreateLipsyncJobRequest/AiAvatarTtsPreviewRequest)
- schemas新增volume字段(TTSSynthesizeRequest/CreateLipsyncJobRequest)
- cosyvoice_service新增STYLE_INSTRUCTION_MAP和build_style_instruction()
- submit_synthesize_task新增style/pitch参数支持
- workflow start_synthesis/resynthesize/submit_segments全链路传递style/volume/pitch
- tts.py路由/lipsync.py路由/lipsync_service.py全链路透传新参数
- lipsync_tts.py Celery task支持style/volume参数
2026-09-20 15:52:53 +08:00
10 changed files with 752 additions and 121 deletions
+3
View File
@@ -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:
+5 -1
View File
@@ -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:
# 合成失败退费
+9
View File
@@ -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):
+10 -1
View File
@@ -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):
+6
View File
@@ -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
+4
View File
@@ -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)
+218
View File
@@ -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
- **格式**: WAV16bit 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),这个无论推理代码如何都是必须的
+394 -113
View File
@@ -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 WAVMuseTalk 要求)
- 模型懒加载:首次推理时加载,后续复用,避免重复加载
- 视频帧循环使用 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 v2MuseTalk 直接处理全量音频,输出时长=音频时长,无需预处理循环。
可选 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)
+87 -6
View File
@@ -71,6 +71,28 @@ EMOTION_MAP: dict[str, str] = {
"friendly": "happy",
}
# ── Style(语气风格)→ Instruct 指令映射(CosyVoice v3 Instruct 模式)──
# 前端传 6 种 stylenatural/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 必须使用中文固定格式 "你说话的情感是<emotion>。"
@@ -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 优先级高于 emotionstyle 是新的 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(
+16
View File
@@ -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