Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ebccd61d9 |
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
# 合成失败退费
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),这个无论推理代码如何都是必须的
|
||||
@@ -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)
|
||||
|
||||
@@ -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 必须使用中文固定格式 "你说话的情感是<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 优先级高于 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user