rag基座已经搭建好
This commit is contained in:
1 parent
64cabe63d7
commit
87ee138fb5
17 files changed
+816
-251
No files matched your search
@@ -9,8 +9,9 @@ REDIS_URI=redis://localhost:6379/0
|
||||
# LLM_BASE_URL="http://192.168.137.96:8000/v1"
|
||||
# LLM_MODEL = "Qwen/Qwen3.6-35B-A3B-FP8"
|
||||
# ASR_TTS_BASE_URL = "http://192.168.137.96:8100"
|
||||
TTS_BASE_URL = "http://192.168.137.96:8902"
|
||||
ASR_BASE_URL = "http://192.168.137.96:8901/v1"
|
||||
TTS_BASE_URL = "http://192.168.137.96:8902"
|
||||
RAG_BASE_URL = "http://192.168.137.96:8903"
|
||||
|
||||
#6000pro:
|
||||
LLM_API_KEY='semi-ai.tech'
|
||||
|
||||
@@ -1,30 +1,42 @@
|
||||
cd .\Agent\
|
||||
# 进入项目目录并提交代码(Windows 下路径为 .\Agent\)
|
||||
cd Agent
|
||||
git add .
|
||||
git commit -m "修改内容的说明"
|
||||
git push
|
||||
|
||||
# 创建并激活虚拟环境(Linux/macOS 用 source,Windows 用 myenv\Scripts\activate)
|
||||
python -m venv myenv
|
||||
source myenv/bin/activate || myenv/Scripts/activate
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U langchain langchain-openai dotenv
|
||||
pip install -e .(详情见pyproject.toml)
|
||||
pip install langgraph-cli[inmem]
|
||||
pip install sqlalchemy pymysql loguru psycopg2-binary(postgresql) *可选数据库开发
|
||||
source myenv/bin/activate || myenv/Scripts/activate
|
||||
|
||||
# Environment
|
||||
Copy `.env.example` to `.env` and fill in the required values before running the app.
|
||||
# 升级 pip 并安装核心依赖
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U langchain langchain-openai python-dotenv
|
||||
|
||||
# 以可编辑模式安装当前项目(配置见 pyproject.toml)
|
||||
pip install -e .
|
||||
|
||||
# 安装 LangGraph CLI(开发模式)
|
||||
pip install "langgraph-cli[inmem]"
|
||||
|
||||
# 可选:安装数据库相关依赖(psycopg2-binary 用于 PostgreSQL)
|
||||
pip install sqlalchemy pymysql loguru psycopg2-binary
|
||||
|
||||
# 环境变量配置
|
||||
# Copy .env.example to .env and fill in the required values before running the app.
|
||||
cp .env.example .env
|
||||
|
||||
# Required variables
|
||||
# - LLM_BASE_URL
|
||||
# - LLM_API_KEY
|
||||
# - LLM_MODEL
|
||||
# 必须填写的变量:
|
||||
# .env.example
|
||||
|
||||
# Web 对话(推荐):在 ``Agent`` 目录下保证 ``PYTHONPATH`` 包含 ``src`` 后执行
|
||||
# 启动 Web 对话服务(推荐)
|
||||
# 确保在 Agent 目录下,且 PYTHONPATH 包含 src 目录
|
||||
cd ./Agent
|
||||
export PYTHONPATH=src:$PYTHONPATH # Linux/macOS
|
||||
# 或 Windows PowerShell: $env:PYTHONPATH="src;$env:PYTHONPATH"
|
||||
uvicorn agent.web.server:app --host 127.0.0.1 --port 8765
|
||||
|
||||
# 发送消息为两阶段 API:``POST /api/chat/start``(立即落盘用户消息)+ ``POST /api/chat/complete``(推理);前端已按此顺序调用。
|
||||
cloudflared tunnel --url http://localhost:8765开公网
|
||||
|
||||
# API 为两阶段调用:POST /api/chat/start(落盘用户消息) + POST /api/chat/complete(推理)
|
||||
# 前端已按此顺序调用。
|
||||
|
||||
# (可选)通过 Cloudflare Tunnel 暴露公网
|
||||
cloudflared tunnel --url http://localhost:8765
|
||||
@@ -1 +0,0 @@
|
||||
目前版本已实现多模态聊天加tool calling
|
||||
@@ -0,0 +1,165 @@
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from modelscope import AutoTokenizer, AutoModel, AutoModelForCausalLM
|
||||
from transformers import BitsAndBytesConfig
|
||||
|
||||
# 在 load_models() 里添加量化配置
|
||||
quantization_config = BitsAndBytesConfig(load_in_8bit=True)
|
||||
# ===================== 模型路径 =====================
|
||||
EMBED_MODEL_PATH = "/home/admin/minor_agent/modelscope/models/Qwen/Qwen3-Embedding-0___6B"
|
||||
RERANK_MODEL_PATH = "/home/admin/minor_agent/modelscope/models/Qwen/Qwen3-Reranker-4B"
|
||||
|
||||
# ===================== 初始化 FastAPI =====================
|
||||
app = FastAPI(title="RAG Backend with 8-bit Qwen3 Models")
|
||||
|
||||
# ===================== 全局变量(模型、分词器) =====================
|
||||
embed_tokenizer = None
|
||||
embed_model = None
|
||||
rerank_tokenizer = None
|
||||
rerank_model = None
|
||||
|
||||
# ===================== 工具函数 =====================
|
||||
def last_token_pool(last_hidden_states, attention_mask):
|
||||
left_padding = (attention_mask[:, -1].sum() == attention_mask.shape[0])
|
||||
if left_padding:
|
||||
return last_hidden_states[:, -1]
|
||||
else:
|
||||
sequence_lengths = attention_mask.sum(dim=1) - 1
|
||||
batch_size = last_hidden_states.shape[0]
|
||||
return last_hidden_states[torch.arange(batch_size, device=last_hidden_states.device), sequence_lengths]
|
||||
|
||||
def get_detailed_instruct(task_description: str, query: str) -> str:
|
||||
return f'Instruct: {task_description}\nQuery:{query}'
|
||||
|
||||
def format_rerank_instruction(instruction, query, doc):
|
||||
if instruction is None:
|
||||
instruction = 'Given a web search query, retrieve relevant passages that answer the query'
|
||||
return f"<Instruct>: {instruction}\n<Query>: {query}\n<Document>: {doc}"
|
||||
|
||||
# ===================== 启动时加载模型(8‑bit) =====================
|
||||
@app.on_event("startup")
|
||||
def load_models():
|
||||
global embed_tokenizer, embed_model, rerank_tokenizer, rerank_model
|
||||
|
||||
# ---------- 加载 Embedding 模型 ----------
|
||||
print("Loading Embedding model with 16-bit quantization...")
|
||||
embed_tokenizer = AutoTokenizer.from_pretrained(EMBED_MODEL_PATH, padding_side='left')
|
||||
embed_model = AutoModel.from_pretrained(
|
||||
EMBED_MODEL_PATH,
|
||||
#load_in_8bit=True, # 启用 8-bit 量化
|
||||
device_map="auto", # 自动分配到 GPU
|
||||
dtype=torch.float16 # 计算精度保持 fp16
|
||||
)
|
||||
embed_model.eval()
|
||||
print("Embedding model loaded.")
|
||||
|
||||
# ---------- 加载 Reranker 模型 ----------
|
||||
print("Loading Reranker model with 8-bit quantization...")
|
||||
rerank_tokenizer = AutoTokenizer.from_pretrained(RERANK_MODEL_PATH, padding_side='left')
|
||||
rerank_model = AutoModelForCausalLM.from_pretrained(
|
||||
RERANK_MODEL_PATH,
|
||||
quantization_config=quantization_config,
|
||||
device_map="auto",
|
||||
dtype=torch.float16
|
||||
)
|
||||
rerank_model.eval()
|
||||
|
||||
# 预计算 Reranker 所需的固定 tokens
|
||||
global token_true_id, token_false_id, prefix_tokens, suffix_tokens, max_rerank_length
|
||||
token_false_id = rerank_tokenizer.convert_tokens_to_ids("no")
|
||||
token_true_id = rerank_tokenizer.convert_tokens_to_ids("yes")
|
||||
max_rerank_length = 8192
|
||||
|
||||
prefix = "<|im_start|>system\nJudge whether the Document meets the requirements based on the Query and the Instruct provided. Note that the answer can only be \"yes\" or \"no\".<|im_end|>\n<|im_start|>user\n"
|
||||
suffix = "<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n"
|
||||
prefix_tokens = rerank_tokenizer.encode(prefix, add_special_tokens=False)
|
||||
suffix_tokens = rerank_tokenizer.encode(suffix, add_special_tokens=False)
|
||||
|
||||
print("Reranker model loaded.")
|
||||
|
||||
# ===================== API 请求体定义 =====================
|
||||
class EmbedRequest(BaseModel):
|
||||
task_description: str = "Given a web search query, retrieve relevant passages that answer the query"
|
||||
queries: List[str]
|
||||
documents: List[str] # 可选,若只查询文本可只传 queries
|
||||
|
||||
class EmbedResponse(BaseModel):
|
||||
query_embeddings: List[List[float]]
|
||||
doc_embeddings: List[List[float]] # 当传入 documents 时返回
|
||||
|
||||
class RerankRequest(BaseModel):
|
||||
task_description: str = "Given a web search query, retrieve relevant passages that answer the query"
|
||||
query: str
|
||||
documents: List[str]
|
||||
|
||||
class RerankResponse(BaseModel):
|
||||
scores: List[float]
|
||||
|
||||
# ===================== Embedding 端点 =====================
|
||||
@app.post("/embed", response_model=EmbedResponse)
|
||||
async def get_embeddings(req: EmbedRequest):
|
||||
# 为 query 加上指令,document 不加指令
|
||||
instructed_queries = [get_detailed_instruct(req.task_description, q) for q in req.queries]
|
||||
# 所有文本拼成一个 batch:先是 queries,后是 documents(如果有)
|
||||
input_texts = instructed_queries + req.documents
|
||||
|
||||
# 分词
|
||||
batch_dict = embed_tokenizer(
|
||||
input_texts,
|
||||
padding=True,
|
||||
truncation=True,
|
||||
max_length=8192,
|
||||
return_tensors="pt",
|
||||
).to(embed_model.device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = embed_model(**batch_dict)
|
||||
embeddings = last_token_pool(outputs.last_hidden_state, batch_dict['attention_mask'])
|
||||
embeddings = F.normalize(embeddings, p=2, dim=1)
|
||||
|
||||
# 拆分 query 和 document 的嵌入
|
||||
nq = len(instructed_queries)
|
||||
query_embs = embeddings[:nq].cpu().tolist()
|
||||
doc_embs = embeddings[nq:].cpu().tolist() if req.documents else []
|
||||
|
||||
return EmbedResponse(query_embeddings=query_embs, doc_embeddings=doc_embs)
|
||||
|
||||
# ===================== Reranker 端点 =====================
|
||||
@app.post("/rerank", response_model=RerankResponse)
|
||||
async def rerank_documents(req: RerankRequest):
|
||||
# 构造带格式的输入对
|
||||
pairs = [
|
||||
format_rerank_instruction(req.task_description, req.query, doc)
|
||||
for doc in req.documents
|
||||
]
|
||||
|
||||
# 处理输入,添加 prefix/suffix tokens 并 pad
|
||||
inputs = rerank_tokenizer(
|
||||
pairs,
|
||||
padding=False,
|
||||
truncation='longest_first',
|
||||
return_attention_mask=False,
|
||||
max_length=max_rerank_length - len(prefix_tokens) - len(suffix_tokens)
|
||||
)
|
||||
for i, ele in enumerate(inputs['input_ids']):
|
||||
inputs['input_ids'][i] = prefix_tokens + ele + suffix_tokens
|
||||
inputs = rerank_tokenizer.pad(inputs, padding=True, return_tensors="pt", max_length=max_rerank_length)
|
||||
inputs = {k: v.to(rerank_model.device) for k, v in inputs.items()}
|
||||
|
||||
with torch.no_grad():
|
||||
batch_scores = rerank_model(**inputs).logits[:, -1, :]
|
||||
true_vector = batch_scores[:, token_true_id]
|
||||
false_vector = batch_scores[:, token_false_id]
|
||||
scores = torch.stack([false_vector, true_vector], dim=1)
|
||||
scores = torch.nn.functional.log_softmax(scores, dim=1)
|
||||
scores = scores[:, 1].exp().cpu().tolist()
|
||||
|
||||
return RerankResponse(scores=scores)
|
||||
|
||||
# ===================== 健康检查 =====================
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
@@ -85,3 +85,4 @@ async def text_to_speech(text: str = Query(...)):
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "models": list(models.keys())}
|
||||
|
||||
+34
-52
@@ -1,62 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 配置区域(可根据实际情况修改)
|
||||
MODEL_BASE_PATH="${MODEL_BASE_PATH:-/mnt/Qwen3-Omni-30B-A3B-Instruct-NVFP4}"
|
||||
THINKER_PATH="${MODEL_BASE_PATH}/thinker"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-vllm-thinker}"
|
||||
HOST_PORT="${HOST_PORT:-8091}"
|
||||
GPU_DEVICES="${GPU_DEVICES:-all}" # 例如 "0,1" 或 "all"
|
||||
TENSOR_PARALLEL_SIZE="${TENSOR_PARALLEL_SIZE:-1}"
|
||||
MAX_MODEL_LEN="${MAX_MODEL_LEN:-32768}"
|
||||
VLLM_IMAGE="${VLLM_IMAGE:-vllm/vllm-openai:latest}"
|
||||
# 自动获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="${SCRIPT_DIR}/.venv_audio"
|
||||
MODEL_DIR="${SCRIPT_DIR}/modelscope/models/Qwen/Qwen3-ASR-1.7B"
|
||||
HOST_PORT="${HOST_PORT:-8901}"
|
||||
|
||||
# 检查模型路径是否存在
|
||||
if [[ ! -d "$THINKER_PATH" ]]; then
|
||||
echo "错误: 模型路径不存在: $THINKER_PATH"
|
||||
echo "请设置环境变量 MODEL_BASE_PATH 指向正确的模型根目录(包含 thinker 子目录)"
|
||||
# 激活虚拟环境
|
||||
source "${VENV_DIR}/bin/activate"
|
||||
|
||||
# 检查端口占用
|
||||
if lsof -i "tcp:${HOST_PORT}" -t >/dev/null 2>&1; then
|
||||
echo "Port ${HOST_PORT} already in use" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Docker 可用性
|
||||
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
DOCKER_CMD="docker"
|
||||
else
|
||||
DOCKER_CMD="sudo docker"
|
||||
fi
|
||||
echo "Starting vLLM server on port ${HOST_PORT} ..."
|
||||
echo "Model path: ${MODEL_DIR}"
|
||||
|
||||
# 停止并移除可能已存在的同名容器
|
||||
${DOCKER_CMD} rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
||||
# 进入脚本目录
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
echo "启动 Thinker 服务..."
|
||||
echo "模型路径: $THINKER_PATH"
|
||||
echo "服务端口: $HOST_PORT"
|
||||
echo "Tensor Parallel Size: $TENSOR_PARALLEL_SIZE"
|
||||
# 设置环境变量
|
||||
export VLLM_USE_MODELSCOPE=true
|
||||
export MODELSCOPE_DOWNLOAD_MODE=local
|
||||
export MODELSCOPE_HUB_ENABLE_FILE_CHECK=false
|
||||
export MODELSCOPE_CACHE="${SCRIPT_DIR}/modelscope"
|
||||
export HF_HUB_OFFLINE=1
|
||||
export TRANSFORMERS_OFFLINE=1
|
||||
|
||||
# 启动容器
|
||||
${DOCKER_CMD} run -d \
|
||||
--name "${CONTAINER_NAME}" \
|
||||
--gpus "${GPU_DEVICES}" \
|
||||
--ipc=host \
|
||||
--network host \
|
||||
-v "${THINKER_PATH}:/model:ro" \
|
||||
-e VLLM_USE_MODELSCOPE=false \
|
||||
"${VLLM_IMAGE}" \
|
||||
sh -c "pip install --no-cache-dir nvidia-modelopt && \
|
||||
vllm serve /model \
|
||||
--port ${HOST_PORT} \
|
||||
--quantization modelopt_fp4 \
|
||||
--dtype bfloat16 \
|
||||
--max-model-len ${MAX_MODEL_LEN} \
|
||||
--tensor-parallel-size ${TENSOR_PARALLEL_SIZE} \
|
||||
--trust-remote-code \
|
||||
--enforce-eager"
|
||||
|
||||
# 等待服务启动
|
||||
sleep 3
|
||||
|
||||
# 健康检查
|
||||
echo "健康检查 (等待几秒后):"
|
||||
echo " curl http://127.0.0.1:${HOST_PORT}/health"
|
||||
echo "查看日志:"
|
||||
echo " ${DOCKER_CMD} logs -f ${CONTAINER_NAME}"
|
||||
# 启动 vLLM 服务
|
||||
vllm serve "${MODEL_DIR}" \
|
||||
--port "${HOST_PORT}" \
|
||||
--host 0.0.0.0 \
|
||||
--quantization fp8 \
|
||||
--dtype auto \
|
||||
--kv-cache-dtype fp8 \
|
||||
--max-model-len 16384 \
|
||||
--allowed-local-media-path / \
|
||||
--tensor-parallel-size 1 \
|
||||
--gpu-memory-utilization 0.10 \
|
||||
--max-num-batched-tokens 4096
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 自动获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="${VENV_DIR:-${SCRIPT_DIR}/.venv_audio}"
|
||||
HOST_PORT="${HOST_PORT:-8903}"
|
||||
|
||||
# 激活虚拟环境
|
||||
source "${VENV_DIR}/bin/activate"
|
||||
|
||||
# 检查端口占用
|
||||
if lsof -i "tcp:${HOST_PORT}" -t >/dev/null 2>&1; then
|
||||
echo "Port ${HOST_PORT} already in use" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting Qwen3 RAG server on port ${HOST_PORT} ..."
|
||||
|
||||
# 进入脚本目录
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
# 离线模式设置(确保已下载模型,不从远程拉取)
|
||||
export MODELSCOPE_DOWNLOAD_MODE=local
|
||||
export MODELSCOPE_HUB_ENABLE_FILE_CHECK=false
|
||||
export HF_HUB_OFFLINE=1
|
||||
export TRANSFORMERS_OFFLINE=1
|
||||
|
||||
# 指定 modelscope 的缓存目录(如果你的模型保存在此路径下)
|
||||
# 根据你的下载路径调整,或将其设置为环境变量覆盖
|
||||
export MODELSCOPE_CACHE="${MODELSCOPE_CACHE:-/home/admin/minor_agent/modelscope}"
|
||||
|
||||
# 启动 FastAPI 服务
|
||||
uvicorn rag_server:app \
|
||||
--host 0.0.0.0 \
|
||||
--port "${HOST_PORT}" \
|
||||
--workers 1
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 自动获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV_DIR="${SCRIPT_DIR}/.venv_audio"
|
||||
HOST_PORT="${HOST_PORT:-8902}"
|
||||
|
||||
# 激活虚拟环境
|
||||
source "${VENV_DIR}/bin/activate"
|
||||
|
||||
# 检查端口占用
|
||||
if lsof -i "tcp:${HOST_PORT}" -t >/dev/null 2>&1; then
|
||||
echo "Port ${HOST_PORT} already in use" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Starting Genie TTS server on port ${HOST_PORT} ..."
|
||||
|
||||
# 进入脚本目录
|
||||
cd "${SCRIPT_DIR}"
|
||||
|
||||
# 设置环境变量(和 vLLM 保持一致)
|
||||
export MODELSCOPE_DOWNLOAD_MODE=local
|
||||
export MODELSCOPE_HUB_ENABLE_FILE_CHECK=false
|
||||
export MODELSCOPE_CACHE="${SCRIPT_DIR}/modelscope"
|
||||
export HF_HUB_OFFLINE=1
|
||||
export TRANSFORMERS_OFFLINE=1
|
||||
|
||||
# 启动 FastAPI 服务
|
||||
uvicorn tts_server:app \
|
||||
--host 0.0.0.0 \
|
||||
--port "${HOST_PORT}" \
|
||||
--workers 1 \
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-vllm-thinker}"
|
||||
|
||||
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
|
||||
DOCKER_CMD="docker"
|
||||
else
|
||||
DOCKER_CMD="sudo docker"
|
||||
fi
|
||||
|
||||
echo "停止并移除容器: $CONTAINER_NAME"
|
||||
${DOCKER_CMD} rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
||||
echo "已停止"
|
||||
@@ -1,14 +0,0 @@
|
||||
#!usrbinenv bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST_PORT=${HOST_PORT-8100}
|
||||
|
||||
PID=$(lsof -i tcp${HOST_PORT} -t 2devnull true)
|
||||
|
||||
if [[ -z ${PID} ]]; then
|
||||
echo No process found on port ${HOST_PORT}
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo Stopping audio server (PID ${PID}) ...
|
||||
kill ${PID}
|
||||
Whitespace-only changes.
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
语音服务功能测试:ASR 识别 + TTS 合成
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = "http://127.0.0.1:8100"
|
||||
|
||||
def test_health():
|
||||
"""1. 健康检查"""
|
||||
print("=" * 50)
|
||||
print("📋 健康检查")
|
||||
print("=" * 50)
|
||||
resp = requests.get(f"{HOST}/health")
|
||||
print(f"状态码: {resp.status_code}")
|
||||
print(f"模型列表: {resp.json()}")
|
||||
assert resp.status_code == 200
|
||||
assert "asr" in resp.json()["models"]
|
||||
assert "tts" in resp.json()["models"]
|
||||
print("✅ 健康检查通过\n")
|
||||
|
||||
def test_asr(audio_path=None):
|
||||
"""2. 测试语音识别"""
|
||||
print("=" * 50)
|
||||
print("🎤 语音识别测试")
|
||||
print("=" * 50)
|
||||
|
||||
if audio_path is None:
|
||||
# 如果没有本地音频,下载一个公开示例
|
||||
import tempfile
|
||||
audio_path = tempfile.mktemp(suffix=".wav")
|
||||
url = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_zh.wav"
|
||||
print(f"下载测试音频: {url}")
|
||||
import subprocess
|
||||
subprocess.run(["wget", "-q", url, "-O", audio_path], check=True)
|
||||
|
||||
print(f"音频文件: {audio_path}")
|
||||
with open(audio_path, "rb") as f:
|
||||
resp = requests.post(f"{HOST}/asr", files={"file": f})
|
||||
|
||||
print(f"状态码: {resp.status_code}")
|
||||
result = resp.json()
|
||||
print(f"识别结果: {result.get('text', 'N/A')}")
|
||||
assert resp.status_code == 200
|
||||
assert len(result.get("text", "")) > 0
|
||||
print("✅ 语音识别通过\n")
|
||||
return result["text"]
|
||||
|
||||
def test_tts(text="(激动地说)你好世界,这是一段测试语音", output="test_output.wav"):
|
||||
"""3. 测试语音合成"""
|
||||
print("=" * 50)
|
||||
print("🔊 语音合成测试")
|
||||
print("=" * 50)
|
||||
print(f"合成文本: {text}")
|
||||
|
||||
start_time = time.time()
|
||||
resp = requests.get(f"{HOST}/tts", params={"text": text})
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
print(f"状态码: {resp.status_code}")
|
||||
print(f"耗时: {elapsed:.2f}秒")
|
||||
|
||||
if resp.status_code == 200:
|
||||
with open(output, "wb") as f:
|
||||
f.write(resp.content)
|
||||
file_size = os.path.getsize(output) / 1024 # KB
|
||||
print(f"音频已保存: {output} ({file_size:.1f} KB)")
|
||||
else:
|
||||
print(f"错误: {resp.text}")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert os.path.exists(output)
|
||||
print("✅ 语音合成通过\n")
|
||||
|
||||
def test_asr_english():
|
||||
"""4. 测试英文识别"""
|
||||
print("=" * 50)
|
||||
print("🌍 英文识别测试")
|
||||
print("=" * 50)
|
||||
|
||||
import tempfile
|
||||
audio_path = tempfile.mktemp(suffix=".wav")
|
||||
url = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-ASR-Repo/asr_en.wav"
|
||||
print(f"下载英文测试音频: {url}")
|
||||
import subprocess
|
||||
subprocess.run(["wget", "-q", url, "-O", audio_path], check=True)
|
||||
|
||||
with open(audio_path, "rb") as f:
|
||||
resp = requests.post(f"{HOST}/asr", files={"file": f})
|
||||
|
||||
print(f"状态码: {resp.status_code}")
|
||||
result = resp.json()
|
||||
print(f"识别结果: {result.get('text', 'N/A')}")
|
||||
assert resp.status_code == 200
|
||||
assert len(result.get("text", "")) > 0
|
||||
print("✅ 英文识别通过\n")
|
||||
|
||||
def test_tts_long_text():
|
||||
"""5. 测试长文本合成"""
|
||||
print("=" * 50)
|
||||
print("📜 长文本合成测试")
|
||||
print("=" * 50)
|
||||
|
||||
long_text = """人工智能是计算机科学的一个分支,它企图了解智能的实质,
|
||||
并生产出一种新的能以人类智能相似的方式做出反应的智能机器,
|
||||
该领域的研究包括机器人、语言识别、图像识别、自然语言处理和专家系统等。"""
|
||||
|
||||
print(f"文本长度: {len(long_text)} 字")
|
||||
start_time = time.time()
|
||||
resp = requests.get(f"{HOST}/tts", params={"text": long_text})
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
print(f"状态码: {resp.status_code}")
|
||||
print(f"耗时: {elapsed:.2f}秒")
|
||||
|
||||
if resp.status_code == 200:
|
||||
output = "test_long_output.wav"
|
||||
with open(output, "wb") as f:
|
||||
f.write(resp.content)
|
||||
file_size = os.path.getsize(output) / 1024
|
||||
print(f"音频已保存: {output} ({file_size:.1f} KB)")
|
||||
|
||||
assert resp.status_code == 200
|
||||
print("✅ 长文本合成通过\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("\n" + "🚀 开始测试语音服务".center(50, "="))
|
||||
print(f"服务地址: {HOST}\n")
|
||||
|
||||
try:
|
||||
test_health()
|
||||
|
||||
# 可选:用自己的音频文件测试
|
||||
# test_asr("/path/to/your/audio.wav")
|
||||
test_asr()
|
||||
test_asr_english()
|
||||
|
||||
test_tts()
|
||||
test_tts_long_text()
|
||||
|
||||
print("\n" + "🎉 所有测试通过!".center(50, "="))
|
||||
except Exception as e:
|
||||
print(f"\n❌ 测试失败: {e}")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Test script for Qwen3-Omni-30B-A3B-Captioner via vLLM Chat Completions API.
|
||||
|
||||
Note:
|
||||
This model is an audio captioner, not a pure ASR. It generates
|
||||
detailed descriptions of audio content (speech, environment, music, etc.)
|
||||
without any text prompt. Only the audio file is sent.
|
||||
It is recommended to keep audio length <= 30 seconds for best detail.
|
||||
"""
|
||||
|
||||
import requests
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
DEFAULT_URL = "http://127.0.0.1:8901/v1/chat/completions"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Optional: check audio duration (requires soundfile)
|
||||
# ----------------------------------------------------------------------
|
||||
def get_audio_duration_sec(file_path: str) -> float:
|
||||
"""Return audio duration in seconds, or -1.0 if unable to read."""
|
||||
try:
|
||||
import soundfile as sf
|
||||
info = sf.info(file_path)
|
||||
return info.duration
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback using librosa (if available)
|
||||
import librosa
|
||||
return librosa.get_duration(path=file_path)
|
||||
except Exception:
|
||||
return -1.0
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
def file_to_url(file_path: str) -> str:
|
||||
"""Convert local file path to a file:// URL accepted by vLLM."""
|
||||
abs_path = os.path.abspath(file_path)
|
||||
return "file://" + abs_path.replace("\\", "/")
|
||||
|
||||
def caption_audio(audio_path: str, server_url: str = DEFAULT_URL) -> str:
|
||||
"""
|
||||
Send audio to the Qwen3-Omni-30B-A3B-Captioner and return its description.
|
||||
"""
|
||||
if not os.path.exists(audio_path):
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
|
||||
# Check duration (warning only, not blocking)
|
||||
duration = get_audio_duration_sec(audio_path)
|
||||
if duration > 30:
|
||||
print(
|
||||
f"⚠️ Audio length is {duration:.1f}s (>30s). "
|
||||
"The model may lose fine detail. Consider trimming to ≤30s.",
|
||||
file=sys.stderr
|
||||
)
|
||||
|
||||
audio_url = file_to_url(audio_path)
|
||||
|
||||
# The model expects NO text prompt. Only the audio input is required.
|
||||
payload = {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "audio_url", "audio_url": {"url": audio_url}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
try:
|
||||
resp = requests.post(server_url, headers=headers, json=payload, timeout=120)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
# Extract assistant reply
|
||||
text = result["choices"][0]["message"]["content"]
|
||||
return text.strip()
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Request failed: {e}", file=sys.stderr)
|
||||
if hasattr(e, 'response') and e.response is not None:
|
||||
print(f"Server response: {e.response.text}", file=sys.stderr)
|
||||
raise
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test Qwen3-Omni-Captioner (audio captioning, NOT pure ASR)"
|
||||
)
|
||||
parser.add_argument("--audio", "-a", required=True,
|
||||
help="Path to input audio file (mp3, wav, etc.)")
|
||||
parser.add_argument("--url", "-u", default=DEFAULT_URL,
|
||||
help="vLLM chat completions endpoint")
|
||||
parser.add_argument("--output", "-o", default=None,
|
||||
help="Output text file (if not provided, print to stdout)")
|
||||
parser.add_argument("--verbose", "-v", action="store_true",
|
||||
help="Print detailed request/response info")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
print(f"Audio file: {args.audio}")
|
||||
print(f"Server URL: {args.url}")
|
||||
|
||||
caption_text = caption_audio(args.audio, args.url)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(caption_text)
|
||||
print(f"Caption saved to: {args.output}")
|
||||
else:
|
||||
print(caption_text)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,82 @@
|
||||
import requests
|
||||
import json
|
||||
|
||||
# 服务地址
|
||||
BASE_URL = "http://0.0.0.0:8903"
|
||||
|
||||
def print_separator(title):
|
||||
print("\n" + "=" * 80)
|
||||
print(f" {title}")
|
||||
print("=" * 80)
|
||||
|
||||
def test_health():
|
||||
print_separator("Health Check")
|
||||
resp = requests.get(f"{BASE_URL}/health")
|
||||
print(f"Status: {resp.status_code}")
|
||||
print(f"Response: {resp.json()}")
|
||||
|
||||
def test_embed():
|
||||
print_separator("Embedding Test")
|
||||
payload = {
|
||||
"task_description": "Given a web search query, retrieve relevant passages that answer the query",
|
||||
"queries": [
|
||||
"What is the capital of France?",
|
||||
"How to train a cat?"
|
||||
],
|
||||
"documents": [
|
||||
"Paris is the capital of France.",
|
||||
"Cats can be trained using positive reinforcement."
|
||||
]
|
||||
}
|
||||
print(">>> Request Body:")
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
|
||||
resp = requests.post(f"{BASE_URL}/embed", json=payload)
|
||||
print(f"<<< Status: {resp.status_code}")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
# 只打印向量长度和前面几个值,避免刷屏
|
||||
print(f"<<< Response Keys: {list(data.keys())}")
|
||||
print(f"Number of query embeddings: {len(data['query_embeddings'])}")
|
||||
print(f"Dimension of each query embedding: {len(data['query_embeddings'][0])}")
|
||||
print(f"First query embedding (first 5 values): {data['query_embeddings'][0][:5]}")
|
||||
print(f"Number of doc embeddings: {len(data['doc_embeddings'])}")
|
||||
if data['doc_embeddings']:
|
||||
print(f"Dimension of each doc embedding: {len(data['doc_embeddings'][0])}")
|
||||
print(f"First doc embedding (first 5 values): {data['doc_embeddings'][0][:5]}")
|
||||
else:
|
||||
print("No document embeddings returned.")
|
||||
# 如果需要完整输出,取消下面注释(会打印大量浮点数)
|
||||
# print(f"<<< Full Response:\n{json.dumps(data, indent=2)}")
|
||||
else:
|
||||
print(f"Error: {resp.text}")
|
||||
|
||||
def test_rerank():
|
||||
print_separator("Reranker Test")
|
||||
payload = {
|
||||
"task_description": "Given a web search query, retrieve relevant passages that answer the query",
|
||||
"query": "What is the capital of France?",
|
||||
"documents": [
|
||||
"Paris is the capital of France.",
|
||||
"Cats are often difficult to train.",
|
||||
"Lyon is a city in France."
|
||||
]
|
||||
}
|
||||
print(">>> Request Body:")
|
||||
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||
|
||||
resp = requests.post(f"{BASE_URL}/rerank", json=payload)
|
||||
print(f"<<< Status: {resp.status_code}")
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
print(f"<<< Response Keys: {list(data.keys())}")
|
||||
print(f"Scores: {data['scores']}")
|
||||
# 打印完整响应
|
||||
# print(f"<<< Full Response:\n{json.dumps(data, indent=2)}")
|
||||
else:
|
||||
print(f"Error: {resp.text}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_health()
|
||||
test_embed()
|
||||
test_rerank()
|
||||
@@ -0,0 +1,71 @@
|
||||
import requests
|
||||
import argparse
|
||||
import os
|
||||
import time
|
||||
|
||||
# 默认服务地址
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
|
||||
def test_health():
|
||||
"""检查服务是否在线"""
|
||||
resp = requests.get(f"{BASE_URL}/health")
|
||||
print(f"Health check: {resp.status_code} {resp.json()}")
|
||||
|
||||
def test_tts(text, speaker=None, speed=1.0, output="output.wav"):
|
||||
"""
|
||||
调用 TTS 接口并保存音频文件
|
||||
"""
|
||||
params = {"text": text, "speed": speed}
|
||||
if speaker:
|
||||
params["speaker"] = speaker
|
||||
|
||||
print(f"Requesting TTS...")
|
||||
start = time.time()
|
||||
resp = requests.get(f"{BASE_URL}/tts", params=params)
|
||||
elapsed = time.time() - start
|
||||
|
||||
if resp.status_code == 200:
|
||||
with open(output, "wb") as f:
|
||||
f.write(resp.content)
|
||||
print(f"Saved audio to: {output}")
|
||||
print(f"Audio size: {os.path.getsize(output)} bytes")
|
||||
print(f"Time taken: {elapsed:.2f}s")
|
||||
else:
|
||||
print(f"Error {resp.status_code}: {resp.text}")
|
||||
|
||||
def test_speakers():
|
||||
"""尝试获取可用说话人列表(通过 health 端点里可能不返回,这里直接尝试几个常见说话人)"""
|
||||
# MeloTTS 中文常见说话人: ZH, ZH_CN, ZH_TW 等,我们可以列出文档或手动尝试
|
||||
test_speakers = ["ZH", "ZH_CN", "ZH_TW", "EN", "JP"]
|
||||
print("Testing available speakers...")
|
||||
for spk in test_speakers:
|
||||
params = {"text": "测试", "speaker": spk}
|
||||
resp = requests.get(f"{BASE_URL}/tts", params=params)
|
||||
if resp.status_code == 200:
|
||||
print(f" {spk}: OK")
|
||||
else:
|
||||
print(f" {spk}: {resp.text[:50]}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Test MeloTTS API server")
|
||||
parser.add_argument("--url", default=BASE_URL, help="Server base URL")
|
||||
parser.add_argument("--text", default="你好,这是一个语音合成测试。", help="Text to synthesize")
|
||||
parser.add_argument("--speaker", default=None, help="Speaker ID (e.g., ZH)")
|
||||
parser.add_argument("--speed", type=float, default=1.0, help="Speaking speed")
|
||||
parser.add_argument("--output", default="test_output.wav", help="Output WAV filename")
|
||||
parser.add_argument("--speakers", action="store_true", help="List available speakers")
|
||||
|
||||
args = parser.parse_args()
|
||||
BASE_URL = args.url
|
||||
|
||||
# 先检查服务可用性
|
||||
try:
|
||||
test_health()
|
||||
except requests.ConnectionError:
|
||||
print(f"Cannot connect to server at {BASE_URL}. Make sure the FastAPI app is running.")
|
||||
exit(1)
|
||||
|
||||
if args.speakers:
|
||||
test_speakers()
|
||||
else:
|
||||
test_tts(args.text, args.speaker, args.speed, args.output)
|
||||
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Genie TTS 极速服务 - 支持文本分块并行合成 + 线程池预热
|
||||
适配 ARM 大小核架构(Cortex-X925 + Cortex-A725, 20核),推荐 MAX_WORKERS=8
|
||||
"""
|
||||
|
||||
import os
|
||||
os.environ["GENIE_DATA_DIR"] = r"/home/admin/minor_agent/modelscope/hub/models/genie_tts/GenieData"
|
||||
|
||||
import io
|
||||
import tempfile
|
||||
import re
|
||||
import wave
|
||||
import multiprocessing
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.responses import Response
|
||||
import genie_tts as genie
|
||||
|
||||
# ======================== 配置区域 ========================
|
||||
DEFAULT_CHARACTER = "minor"
|
||||
DEFAULT_ONNX_MODEL_DIR = r"/home/admin/minor_agent/modelscope/models/genie_tts/CharacterModels/v2ProPlus/feibi/tts_models"
|
||||
DEFAULT_REFERENCE_AUDIO = "/home/admin/minor_agent/modelscope/models/genie_tts/CharacterModels/v2ProPlus/minor/minor.wav"
|
||||
DEFAULT_REFERENCE_TEXT = "你好你好你好,这是一段...测试录音~"
|
||||
DEFAULT_LANGUAGE = "zh"
|
||||
TMP_DIR = "/dev/shm" # 内存文件系统,减少磁盘 I/O
|
||||
|
||||
# 分块与并行参数
|
||||
MIN_EFFECTIVE_CHARS = 5 # 每个块至少包含的有效字符数
|
||||
MAX_WORKERS = 4 # 建议 4~8,ARM 大小核不要超过大核数(10)
|
||||
POOL_WARMUP = True # 是否在线程池创建后立即预热
|
||||
# =========================================================
|
||||
|
||||
# ---------- 全局线程池(应用生命周期内复用) ----------
|
||||
tts_pool: ThreadPoolExecutor = None
|
||||
|
||||
# ---------- 正则工具 ----------
|
||||
SENT_SPLIT_PATTERN = re.compile(r'(?<=[。!?;,、])')
|
||||
REMOVE_PUNCT_PATTERN = re.compile(r'[。!?;,、:;“”()《》【】\-\_\—\~\.\,\!\?\s·]')
|
||||
|
||||
def effective_length(text: str) -> int:
|
||||
"""计算有效字符数(去除标点、连接符、空格)"""
|
||||
return len(REMOVE_PUNCT_PATTERN.sub('', text))
|
||||
|
||||
def split_text(text: str) -> list[str]:
|
||||
"""将文本切分为多个语义块"""
|
||||
raw_chunks = [c.strip() for c in SENT_SPLIT_PATTERN.split(text) if c.strip()]
|
||||
if not raw_chunks:
|
||||
return [text]
|
||||
|
||||
merged = []
|
||||
for chunk in raw_chunks:
|
||||
if merged and effective_length(chunk) < MIN_EFFECTIVE_CHARS:
|
||||
merged[-1] += chunk
|
||||
else:
|
||||
merged.append(chunk)
|
||||
|
||||
if len(merged) == 1:
|
||||
return merged
|
||||
|
||||
if effective_length(merged[-1]) < MIN_EFFECTIVE_CHARS and len(merged) > 1:
|
||||
merged[-2] += merged[-1]
|
||||
merged.pop()
|
||||
|
||||
return merged
|
||||
|
||||
# ---------- 音频拼接 ----------
|
||||
def merge_wavs(file_paths: list[str]) -> bytes:
|
||||
"""按路径顺序拼接多个 WAV 文件"""
|
||||
if len(file_paths) == 1:
|
||||
with open(file_paths[0], 'rb') as f:
|
||||
return f.read()
|
||||
|
||||
data_parts = []
|
||||
params = None
|
||||
for path in file_paths:
|
||||
with wave.open(path, 'rb') as wf:
|
||||
if params is None:
|
||||
params = wf.getparams()
|
||||
data_parts.append(wf.readframes(wf.getnframes()))
|
||||
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, 'wb') as wf_out:
|
||||
wf_out.setparams(params)
|
||||
for frames in data_parts:
|
||||
wf_out.writeframes(frames)
|
||||
return buf.getvalue()
|
||||
|
||||
# ---------- 预热函数 ----------
|
||||
def _warmup_task(idx: int):
|
||||
"""空任务用于预热线程"""
|
||||
pass
|
||||
|
||||
def warmup_pool(pool: ThreadPoolExecutor, num_workers: int):
|
||||
"""向线程池提交空任务以预热所有线程"""
|
||||
futures = []
|
||||
for i in range(num_workers):
|
||||
futures.append(pool.submit(_warmup_task, i))
|
||||
# 等待所有预热任务完成
|
||||
for f in futures:
|
||||
f.result()
|
||||
print(f"✅ 线程池预热完成 (workers={num_workers})")
|
||||
|
||||
# ---------- 并行 TTS(使用全局线程池) ----------
|
||||
def parallel_tts(chunks: list[str]) -> list[str]:
|
||||
"""
|
||||
多线程并行生成所有语音块,返回按原始顺序的临时文件路径列表。
|
||||
"""
|
||||
tmp_paths = []
|
||||
for _ in chunks:
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, dir=TMP_DIR)
|
||||
tmp.close()
|
||||
tmp_paths.append(tmp.name)
|
||||
|
||||
try:
|
||||
# 使用全局预热的线程池
|
||||
future_to_idx = {}
|
||||
for idx, chunk in enumerate(chunks):
|
||||
future = tts_pool.submit(
|
||||
genie.tts,
|
||||
character_name=DEFAULT_CHARACTER,
|
||||
text=chunk,
|
||||
play=False,
|
||||
save_path=tmp_paths[idx]
|
||||
)
|
||||
future_to_idx[future] = idx
|
||||
|
||||
for future in as_completed(future_to_idx):
|
||||
future.result() # 传递异常
|
||||
except:
|
||||
for p in tmp_paths:
|
||||
if os.path.exists(p):
|
||||
os.unlink(p)
|
||||
raise
|
||||
return tmp_paths
|
||||
|
||||
# ---------- 应用生命周期 ----------
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
global tts_pool
|
||||
|
||||
print("🚀 正在启动 Genie TTS 极简服务...")
|
||||
try:
|
||||
# 1. 加载模型
|
||||
genie.load_character(
|
||||
character_name=DEFAULT_CHARACTER,
|
||||
onnx_model_dir=DEFAULT_ONNX_MODEL_DIR,
|
||||
language=DEFAULT_LANGUAGE
|
||||
)
|
||||
genie.set_reference_audio(
|
||||
character_name=DEFAULT_CHARACTER,
|
||||
audio_path=DEFAULT_REFERENCE_AUDIO,
|
||||
audio_text=DEFAULT_REFERENCE_TEXT,
|
||||
language=DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
# 2. 创建并预热全局线程池
|
||||
print(f"🔥 正在创建线程池并预热 (max_workers={MAX_WORKERS})...")
|
||||
tts_pool = ThreadPoolExecutor(max_workers=MAX_WORKERS)
|
||||
if POOL_WARMUP:
|
||||
warmup_pool(tts_pool, MAX_WORKERS)
|
||||
|
||||
print("✅ Genie TTS 服务启动成功!")
|
||||
print(f" - 线程池: max_workers={MAX_WORKERS}, 已预热")
|
||||
print("👉 调用方式:http://localhost:8000/tts?text=你要说的话")
|
||||
except Exception as e:
|
||||
print(f"❌ 服务启动失败: {e}")
|
||||
raise e
|
||||
|
||||
yield
|
||||
|
||||
print("\n🛑 正在关闭服务...")
|
||||
# 清理线程池
|
||||
if tts_pool:
|
||||
print(" 关闭线程池...")
|
||||
tts_pool.shutdown(wait=True)
|
||||
tts_pool = None
|
||||
|
||||
# 卸载模型
|
||||
try:
|
||||
genie.unload_character(DEFAULT_CHARACTER)
|
||||
print("✅ 资源已释放")
|
||||
except:
|
||||
pass
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# ---------- 极简 API ----------
|
||||
@app.get("/tts")
|
||||
async def text_to_speech(text: str = Query(..., description="待合成的文本")):
|
||||
if not text.strip():
|
||||
return Response(content="文本不能为空", status_code=400, media_type="text/plain")
|
||||
|
||||
chunks = split_text(text)
|
||||
|
||||
try:
|
||||
if len(chunks) == 1:
|
||||
# 单块直接合成,避免多线程开销
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False, dir=TMP_DIR) as tmp:
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
genie.tts(
|
||||
character_name=DEFAULT_CHARACTER,
|
||||
text=chunks[0],
|
||||
play=False,
|
||||
save_path=tmp_path
|
||||
)
|
||||
with open(tmp_path, "rb") as f:
|
||||
audio_bytes = f.read()
|
||||
return Response(content=audio_bytes, media_type="audio/wav")
|
||||
finally:
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
else:
|
||||
# 多块并行合成(使用预热好的线程池)
|
||||
tmp_paths = parallel_tts(chunks)
|
||||
try:
|
||||
merged_audio = merge_wavs(tmp_paths)
|
||||
return Response(content=merged_audio, media_type="audio/wav")
|
||||
finally:
|
||||
for p in tmp_paths:
|
||||
if os.path.exists(p):
|
||||
os.unlink(p)
|
||||
except Exception as e:
|
||||
return Response(content=f"生成失败: {str(e)}", status_code=500, media_type="text/plain")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"pool_active": tts_pool is not None,
|
||||
"max_workers": MAX_WORKERS
|
||||
}
|
||||
|
||||
# ---------- 启动入口 ----------
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
@@ -21,12 +21,13 @@ LLM_BASE_URL = _get_env("LLM_BASE_URL", required=True)
|
||||
LLM_API_KEY = _get_env("LLM_API_KEY", required=True)
|
||||
LLM_MODEL = _get_env("LLM_MODEL", required=True)
|
||||
LLM_TIMEOUT = _get_env("LLM_TIMEOUT", default="60", cast=float)
|
||||
LLM_REQUESTS_PER_SECOND = _get_env("LLM_REQUESTS_PER_SECOND", default="10", cast=float)
|
||||
LLM_MAX_BUCKET_SIZE = _get_env("LLM_MAX_BUCKET_SIZE", default="10", cast=int)
|
||||
LLM_CHECK_EVERY_N_SECONDS = _get_env("LLM_CHECK_EVERY_N_SECONDS", default="0.1", cast=float)
|
||||
ASR_TTS_BASE_URL = _get_env("ASR_TTS_BASE_URL")
|
||||
# LLM_REQUESTS_PER_SECOND = _get_env("LLM_REQUESTS_PER_SECOND", default="10", cast=float)
|
||||
# LLM_MAX_BUCKET_SIZE = _get_env("LLM_MAX_BUCKET_SIZE", default="10", cast=int)
|
||||
# LLM_CHECK_EVERY_N_SECONDS = _get_env("LLM_CHECK_EVERY_N_SECONDS", default="0.1", cast=float)
|
||||
# ASR_TTS_BASE_URL = _get_env("ASR_TTS_BASE_URL")
|
||||
TTS_BASE_URL = _get_env("TTS_BASE_URL")
|
||||
ASR_BASE_URL = _get_env("ASR_BASE_URL")
|
||||
RAG_BASE_URL = _get_env("RAG_BASE_URL")
|
||||
|
||||
BROWSER_MAP: Dict[str, Tuple[str, str]] = {
|
||||
"edge": ("microsoft-edge", "Microsoft Edge"),
|
||||
|
||||
Reference in new issue
Block a user