feat: ChatTTS对话式语音合成服务初始版本
This commit is contained in:
147
README.md
Normal file
147
README.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# ChatTTS 语音合成服务
|
||||||
|
|
||||||
|
专为对话场景设计的 TTS 模型服务,支持情感控制。
|
||||||
|
|
||||||
|
## 端口
|
||||||
|
|
||||||
|
- **服务端口**: 12002
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 对话式语音合成(有语气变化)
|
||||||
|
- 情感控制(开心、悲伤、笑声)
|
||||||
|
- 批量合成
|
||||||
|
- 中文效果好
|
||||||
|
|
||||||
|
## 硬件要求
|
||||||
|
|
||||||
|
- **GPU**: 推荐 NVIDIA GPU,显存 ≥ 4GB
|
||||||
|
- **CPU**: 可运行但较慢
|
||||||
|
- **内存**: ≥ 16GB
|
||||||
|
|
||||||
|
## 部署步骤
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 默认端口 12002
|
||||||
|
python3 server.py
|
||||||
|
|
||||||
|
# 或使用脚本
|
||||||
|
PORT=12002 ./start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
首次启动会自动下载 ChatTTS 模型(约 2GB)。
|
||||||
|
|
||||||
|
### 3. 验证服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:12002/
|
||||||
|
```
|
||||||
|
|
||||||
|
返回:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "ChatTTS",
|
||||||
|
"model_loaded": true,
|
||||||
|
"device": "cuda"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 合成语音
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /synthesize
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- text: 要合成的文本
|
||||||
|
- temperature: 温度参数(默认 0.3)
|
||||||
|
- top_p: Top-P 参数(默认 0.7)
|
||||||
|
- top_k: Top-K 参数(默认 20)
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{
|
||||||
|
"audio_url": "/audio/xxx.wav",
|
||||||
|
"duration": 3.5,
|
||||||
|
"text": "你好",
|
||||||
|
"timestamp": "..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:12002/synthesize \
|
||||||
|
-F "text=你好,很高兴见到你"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 带情感合成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /synthesize/emotion
|
||||||
|
|
||||||
|
参数:
|
||||||
|
- text: 文本
|
||||||
|
- emotion: 情感类型(neutral/happy/sad/laugh)
|
||||||
|
|
||||||
|
返回: 同上
|
||||||
|
```
|
||||||
|
|
||||||
|
**情感类型**:
|
||||||
|
- `neutral`: 平静
|
||||||
|
- `happy`: 开心
|
||||||
|
- `sad`: 悲伤
|
||||||
|
- `laugh`: 笑声
|
||||||
|
|
||||||
|
### 批量合成
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /synthesize/batch
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
Body:
|
||||||
|
[
|
||||||
|
{"text": "第一句"},
|
||||||
|
{"text": "第二句"}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 获取音频文件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /audio/{filename}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特点对比
|
||||||
|
|
||||||
|
| 特点 | Edge TTS | ChatTTS |
|
||||||
|
|------|----------|---------|
|
||||||
|
| 部署 | 无需部署 | 需 GPU |
|
||||||
|
| 质量 | 高 | 对话优化 |
|
||||||
|
| 情感 | 固定音色 | 可控 |
|
||||||
|
| 延迟 | 1-3秒 | 0.5-1秒 |
|
||||||
|
| 离线 | ❌ | ✅ |
|
||||||
|
|
||||||
|
## 与语音对话网页集成
|
||||||
|
|
||||||
|
在 voice-chat-web 的 TTS 设置中选择 ChatTTS,服务地址配置为:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://192.168.2.5:12002
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. 模型首次加载需要下载约 2GB
|
||||||
|
2. GPU 运行速度更快,CPU 可用但较慢
|
||||||
|
3. 长文本建议分段合成
|
||||||
|
4. 情感标记功能需要 ChatTTS 版本支持
|
||||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.110.0
|
||||||
|
uvicorn==0.27.1
|
||||||
|
python-multipart==0.0.9
|
||||||
|
torch==2.2.0
|
||||||
|
torchaudio==2.2.0
|
||||||
|
transformers==4.38.0
|
||||||
|
ChatTTS==0.1.1
|
||||||
315
server.py
Normal file
315
server.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
ChatTTS 语音合成服务
|
||||||
|
专为对话场景设计的 TTS 模型
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
import wave
|
||||||
|
import numpy as np
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torchaudio
|
||||||
|
from fastapi import FastAPI, UploadFile, File, HTTPException, Form
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
PORT = int(os.getenv("PORT", "12002"))
|
||||||
|
SAMPLE_RATE = 24000 # ChatTTS 默认采样率
|
||||||
|
AUDIO_DIR = os.getenv("AUDIO_DIR", "audio_output")
|
||||||
|
os.makedirs(AUDIO_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="ChatTTS Service",
|
||||||
|
description="对话式语音合成服务",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ChatTTS 模型(延迟加载)
|
||||||
|
chat_model = None
|
||||||
|
|
||||||
|
|
||||||
|
class SynthesizeRequest(BaseModel):
|
||||||
|
"""合成请求"""
|
||||||
|
text: str
|
||||||
|
temperature: float = 0.3
|
||||||
|
top_p: float = 0.7
|
||||||
|
top_k: int = 20
|
||||||
|
|
||||||
|
|
||||||
|
class SynthesizeResponse(BaseModel):
|
||||||
|
"""合成响应"""
|
||||||
|
audio_url: str
|
||||||
|
duration: float
|
||||||
|
text: str
|
||||||
|
timestamp: str
|
||||||
|
|
||||||
|
|
||||||
|
def load_model():
|
||||||
|
"""加载 ChatTTS 模型"""
|
||||||
|
global chat_model
|
||||||
|
if chat_model is None:
|
||||||
|
logger.info("Loading ChatTTS model...")
|
||||||
|
try:
|
||||||
|
import ChatTTS
|
||||||
|
chat_model = ChatTTS.Chat()
|
||||||
|
|
||||||
|
# 加载模型
|
||||||
|
# 方式一:从本地加载(如果有)
|
||||||
|
# 方式二:自动下载
|
||||||
|
chat_model.load(
|
||||||
|
compile=True, # 编译优化
|
||||||
|
device="cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("ChatTTS model loaded successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load ChatTTS: {e}")
|
||||||
|
raise
|
||||||
|
return chat_model
|
||||||
|
|
||||||
|
|
||||||
|
def save_audio(audio_tensor: torch.Tensor, filename: str) -> str:
|
||||||
|
"""
|
||||||
|
保存音频文件
|
||||||
|
audio_tensor: shape [1, samples] 或 [samples]
|
||||||
|
"""
|
||||||
|
filepath = os.path.join(AUDIO_DIR, filename)
|
||||||
|
|
||||||
|
# 确保 tensor 正确形状
|
||||||
|
if audio_tensor.dim() == 1:
|
||||||
|
audio_tensor = audio_tensor.unsqueeze(0)
|
||||||
|
|
||||||
|
# 保存为 WAV
|
||||||
|
torchaudio.save(filepath, audio_tensor, SAMPLE_RATE, format="wav")
|
||||||
|
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
"""启动时预加载模型"""
|
||||||
|
try:
|
||||||
|
load_model()
|
||||||
|
logger.info(f"ChatTTS service ready on port {PORT}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Model load delayed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
"""健康检查"""
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"service": "ChatTTS",
|
||||||
|
"model_loaded": chat_model is not None,
|
||||||
|
"device": "cuda" if torch.cuda.is_available() else "cpu"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""健康检查"""
|
||||||
|
model_loaded = chat_model is not None
|
||||||
|
return {
|
||||||
|
"status": "ok" if model_loaded else "loading",
|
||||||
|
"gpu": torch.cuda.is_available()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/synthesize", response_model=SynthesizeResponse)
|
||||||
|
async def synthesize(
|
||||||
|
text: str = Form(..., description="要合成的文本"),
|
||||||
|
temperature: float = Form(0.3, description="温度参数"),
|
||||||
|
top_p: float = Form(0.7, description="Top-P参数"),
|
||||||
|
top_k: int = Form(20, description="Top-K参数")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
合成语音
|
||||||
|
|
||||||
|
ChatTTS 特点:
|
||||||
|
- 支持对话场景,有语气变化
|
||||||
|
- 支持笑声、叹气等情感
|
||||||
|
- 中文效果好
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
model = load_model()
|
||||||
|
|
||||||
|
# 生成唯一文件名
|
||||||
|
filename = f"{uuid.uuid4().hex}.wav"
|
||||||
|
|
||||||
|
# 合成参数
|
||||||
|
params = {
|
||||||
|
'temperature': temperature,
|
||||||
|
'top_p': top_p,
|
||||||
|
'top_k': top_k,
|
||||||
|
'spk_emb': None, # 可选:说话人嵌入
|
||||||
|
}
|
||||||
|
|
||||||
|
# 合成语音
|
||||||
|
logger.info(f"Synthesizing: {text[:50]}...")
|
||||||
|
|
||||||
|
# ChatTTS 生成
|
||||||
|
audio_tensor = model.infer(
|
||||||
|
[text],
|
||||||
|
params=params
|
||||||
|
)[0] # 返回是列表,取第一个
|
||||||
|
|
||||||
|
# 保存音频
|
||||||
|
filepath = save_audio(audio_tensor, filename)
|
||||||
|
|
||||||
|
# 计算时长
|
||||||
|
duration = audio_tensor.shape[-1] / SAMPLE_RATE
|
||||||
|
|
||||||
|
audio_url = f"/audio/{filename}"
|
||||||
|
|
||||||
|
logger.info(f"Generated audio: {duration:.2f}s")
|
||||||
|
|
||||||
|
return SynthesizeResponse(
|
||||||
|
audio_url=audio_url,
|
||||||
|
duration=round(duration, 2),
|
||||||
|
text=text,
|
||||||
|
timestamp=datetime.now().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Synthesis error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/synthesize/batch")
|
||||||
|
async def synthesize_batch(requests: list[SynthesizeRequest]):
|
||||||
|
"""
|
||||||
|
批量合成语音
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
model = load_model()
|
||||||
|
|
||||||
|
texts = [r.text for r in requests]
|
||||||
|
|
||||||
|
# 统一参数
|
||||||
|
params = {
|
||||||
|
'temperature': requests[0].temperature,
|
||||||
|
'top_p': requests[0].top_p,
|
||||||
|
'top_k': requests[0].top_k,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 批量生成
|
||||||
|
audio_tensors = model.infer(texts, params=params)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, audio_tensor in enumerate(audio_tensors):
|
||||||
|
filename = f"{uuid.uuid4().hex}.wav"
|
||||||
|
filepath = save_audio(audio_tensor, filename)
|
||||||
|
duration = audio_tensor.shape[-1] / SAMPLE_RATE
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"audio_url": f"/audio/{filename}",
|
||||||
|
"duration": round(duration, 2),
|
||||||
|
"text": texts[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Batch synthesis error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/audio/{filename}")
|
||||||
|
async def get_audio(filename: str):
|
||||||
|
"""获取音频文件"""
|
||||||
|
filepath = os.path.join(AUDIO_DIR, filename)
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
return FileResponse(filepath, media_type="audio/wav")
|
||||||
|
raise HTTPException(status_code=404, detail="Audio not found")
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/audio/{filename}")
|
||||||
|
async def delete_audio(filename: str):
|
||||||
|
"""删除音频文件"""
|
||||||
|
filepath = os.path.join(AUDIO_DIR, filename)
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
return {"status": "deleted"}
|
||||||
|
return {"status": "not_found"}
|
||||||
|
|
||||||
|
|
||||||
|
# 情感控制接口(ChatTTS 特色)
|
||||||
|
@app.post("/synthesize/emotion")
|
||||||
|
async def synthesize_with_emotion(
|
||||||
|
text: str = Form(...),
|
||||||
|
emotion: str = Form("neutral", description="情感:neutral/happy/sad/laugh")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
带情感的语音合成
|
||||||
|
|
||||||
|
ChatTTS 支持情感控制:
|
||||||
|
- neutral: 平静
|
||||||
|
- happy: 开心
|
||||||
|
- sad: 悲伤
|
||||||
|
- laugh: 笑声
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
model = load_model()
|
||||||
|
|
||||||
|
# 根据情感调整参数
|
||||||
|
emotion_params = {
|
||||||
|
'neutral': {'temperature': 0.3, 'top_p': 0.7},
|
||||||
|
'happy': {'temperature': 0.5, 'top_p': 0.8},
|
||||||
|
'sad': {'temperature': 0.2, 'top_p': 0.6},
|
||||||
|
'laugh': {'temperature': 0.6, 'top_p': 0.9},
|
||||||
|
}
|
||||||
|
|
||||||
|
params = emotion_params.get(emotion, emotion_params['neutral'])
|
||||||
|
|
||||||
|
# 添加情感标记(ChatTTS 特有)
|
||||||
|
if emotion == 'laugh':
|
||||||
|
# 添加笑声标记
|
||||||
|
text = f"[laugh]{text}"
|
||||||
|
elif emotion == 'happy':
|
||||||
|
text = f"[happy]{text}"
|
||||||
|
elif emotion == 'sad':
|
||||||
|
text = f"[sad]{text}"
|
||||||
|
|
||||||
|
filename = f"{uuid.uuid4().hex}.wav"
|
||||||
|
audio_tensor = model.infer([text], params=params)[0]
|
||||||
|
|
||||||
|
filepath = save_audio(audio_tensor, filename)
|
||||||
|
duration = audio_tensor.shape[-1] / SAMPLE_RATE
|
||||||
|
|
||||||
|
return SynthesizeResponse(
|
||||||
|
audio_url=f"/audio/{filename}",
|
||||||
|
duration=round(duration, 2),
|
||||||
|
text=text,
|
||||||
|
timestamp=datetime.now().isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Emotion synthesis error: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
Reference in New Issue
Block a user