Files
ylhan 35e107c290 助手本体:会话引擎、LLM 路由、记忆、身份、语音适配
从 Obsidian 笔记库剥离为独立仓库。规划文档与 P0 报告留在笔记库,
代码(助手本体、TTS 服务、P0 基准脚本、ESP32 固件)在此。

架构:服务器是助手,本地是客户端
- 助手本体跑在 cadadmin-server,N5095 只做 USB 外设桥与网络桥接
- 起因:实测 N5095 无 AVX2,跑不动可用的语音模型

当前技术栈
- LLM:DeepSeek deepseek-flash(云端,必须关思考)
- TTS:Qwen3-TTS 1.7B(服务器 3090,独立 HTTP 服务)
- STT:Zipformer 中英双语 int8(CPU,中文识别率未测)

实测:端到端 16/16 通过,首音延迟 1545ms
(对比:本地 LLM + vits-zh-ll 是 179ms,但音色不满意——音质与延迟
在此硬件上此消彼长)

设计要点
- MemoryStore 接口按"可能远程"设计(全 async、可超时、可失败)
- user_id / session_id / track_id 三者分开,人脸只作候选不自动切身份
- 记忆查询强制带 owner_user_id,无按 id 单独取的口子
- 自动抽取的记忆是"候选",默认不参与检索
- 异步写记忆绑定创建时的身份,不读"当前用户"
- 引擎与传输解耦,只用文件音频即可端到端测试

模型与音频产物不进版本管理(1.1GB),由 download_models.sh 恢复
2026-09-15 11:05:09 +08:00

177 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Qwen3-TTS 服务(独立进程)。
为什么要单独起服务,而不是在助手进程里 import:
qwen-tts 和 vllm-omni 的依赖互斥——前者要 transformers 4.57 + hf_hub 0.36,
后者要 5.14 + 1.31。装在一个 venv 里会互相破坏(实测把 vllm-omni 搞崩过)。
所以分成两个进程,用 HTTP 通信。
接口按 **OpenAI 的 /v1/audio/speech 形状**实现:
- 助手那边换 TTS 后端(本地服务 / 云端 API)时调用方不用改
- 多出来的 `instructions` 字段是 Qwen3-TTS 的风格指令,
OpenAI 也有同名字段,语义一致
实测数据(RTX 3090,1.7B,非流式):
RTF 0.55–0.73,输出 24kHz 单声道
flash-attn 装不上(预编译包最高 torch 2.9,本机是 2.14),
所以走的是 PyTorch 手写注意力,比装了 flash-attn 慢。
运行:
~/tts-qwen/.venv/bin/python tts_server.py --port 18040
"""
from __future__ import annotations
import argparse
import asyncio
import io
import logging
import os
import time
import threading
from contextlib import asynccontextmanager
from pathlib import Path
import numpy as np
import soundfile as sf
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
from pydantic import BaseModel, Field
log = logging.getLogger("tts")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# ---------------------------------------------------------------- 默认音色
#: 用户试听选定的组合(见 out/tts_qwen/):Serena 音色 + 轻快活泼的语气。
#: 时长比对确认:instruct_7849.wav 是 1.44s,对应「用轻快活泼的语气说」。
DEFAULT_VOICE = os.environ.get("TTS_DEFAULT_VOICE", "Serena")
DEFAULT_INSTRUCT = os.environ.get("TTS_DEFAULT_INSTRUCT", "用轻快活泼的语气说")
DEFAULT_LANGUAGE = os.environ.get("TTS_DEFAULT_LANGUAGE", "Chinese")
#: 服务端支持的音色(来自模型 README)
VOICES = {
"Vivian": "明亮微冷的年轻女声",
"Serena": "温暖柔和的年轻女声",
"Uncle_Fu": "低沉醇厚的成熟男声",
"Dylan": "清朗自然的京腔男声",
"Eric": "略带沙哑的成都男声",
"Ryan": "富有节奏感的男声(英文)",
"Aiden": "阳光美式男声(英文)",
"Ono_Anna": "轻快俏皮的日语女声",
"Sohee": "温暖富有情感的韩语女声",
}
_model = None
#: 模型是单实例、占用 GPU,并发调用必须串行化,否则会互相踩
_lock = threading.Lock()
class SpeechRequest(BaseModel):
"""OpenAI /v1/audio/speech 的形状,加一个 Qwen 特有的语言字段。"""
input: str = Field(..., description="要合成的文本")
voice: str = Field(DEFAULT_VOICE, description="音色名,见 /v1/voices")
instructions: str | None = Field(DEFAULT_INSTRUCT, description="自然语言风格指令")
language: str = Field(DEFAULT_LANGUAGE, description="语言,中文用 Chinese")
speed: float = Field(1.0, description="语速(本模型通过 instructions 控制,此项仅保留兼容)")
response_format: str = Field("wav", description="输出格式,目前只支持 wav")
@asynccontextmanager
async def lifespan(app: FastAPI):
global _model
model_dir = os.environ.get("QWEN_TTS_MODEL",
os.path.expanduser("~/tts-service/models/Qwen3-TTS-1.7B-CustomVoice"))
log.info("加载模型 %s ...", model_dir)
t0 = time.perf_counter()
from qwen_tts import Qwen3TTSModel
# 在线程里加载,别阻塞事件循环
_model = await asyncio.to_thread(
Qwen3TTSModel.from_pretrained, model_dir, device_map="cuda:0")
log.info("模型就绪,耗时 %.1fs", time.perf_counter() - t0)
yield
_model = None
app = FastAPI(title="Qwen3-TTS Service", lifespan=lifespan)
@app.get("/health")
async def health():
return {"status": "ok" if _model is not None else "loading",
"model_loaded": _model is not None,
"default_voice": DEFAULT_VOICE}
@app.get("/v1/voices")
async def voices():
return {"default": DEFAULT_VOICE, "default_instructions": DEFAULT_INSTRUCT,
"voices": VOICES}
def _synth(text: str, voice: str, instruct: str | None, language: str) -> tuple[np.ndarray, int]:
"""同步合成。调用方负责串行化。"""
kwargs = {"text": text, "language": language, "speaker": voice}
if instruct:
kwargs["instruct"] = instruct
wavs, sr = _model.generate_custom_voice(**kwargs)
# 模型可能按内部切分返回多段,拼起来
if isinstance(wavs, list):
audio = np.concatenate([np.asarray(w, dtype=np.float32) for w in wavs])
else:
audio = np.asarray(wavs, dtype=np.float32)
return audio, sr
@app.post("/v1/audio/speech")
async def speech(req: SpeechRequest):
if _model is None:
raise HTTPException(503, "模型尚未加载完成")
text = (req.input or "").strip()
if not text:
raise HTTPException(400, "input 不能为空")
if req.voice not in VOICES:
raise HTTPException(400, f"未知音色 {req.voice!r},可用:{sorted(VOICES)}")
t0 = time.perf_counter()
try:
# 模型单实例占 GPU,串行执行;放线程里避免阻塞事件循环
audio, sr = await asyncio.to_thread(
lambda: _with_lock(_synth, text, req.voice, req.instructions, req.language))
except Exception as e: # noqa: BLE001
log.exception("合成失败")
raise HTTPException(500, f"合成失败: {type(e).__name__}: {e}") from e
gen_ms = (time.perf_counter() - t0) * 1000
dur_s = len(audio) / sr
rtf = (gen_ms / 1000) / dur_s if dur_s else 0
log.info("合成 %d 字 → %.2fs 音频,耗时 %.0fms(RTF %.2f)",
len(text), dur_s, gen_ms, rtf)
buf = io.BytesIO()
sf.write(buf, audio, sr, format="WAV")
return Response(
content=buf.getvalue(),
media_type="audio/wav",
headers={
"X-Sample-Rate": str(sr),
"X-Duration-Seconds": f"{dur_s:.3f}",
"X-Gen-Ms": f"{gen_ms:.0f}",
"X-RTF": f"{rtf:.3f}",
},
)
def _with_lock(fn, *args):
with _lock:
return fn(*args)
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("--host", default="127.0.0.1")
p.add_argument("--port", type=int, default=18040)
a = p.parse_args()
uvicorn.run(app, host=a.host, port=a.port, log_level="info")