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

139 lines
5.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.
"""远端 TTS 适配器(HTTP 调 Qwen3-TTS 服务)。
**为什么是 HTTP 而不是进程内调用**:qwen-tts 和 vllm-omni/助手的依赖互斥
(transformers 4.57 vs 5.14、hf_hub 0.36 vs 1.31),装一个 venv 里会互相破坏,
实测把 vllm-omni 搞崩过。所以 TTS 跑在独立进程,用 HTTP 通信。
接口和 `LocalTTS` 保持一致(同步的 synthesize / synthesize_sentences),
这样引擎那边一行不用改——切换本地 VITS 和远端 Qwen3-TTS 只是换一个类。
实测延迟(RTX 3090,Serena 音色,非流式):
文本长度 音频 合成耗时 RTF
8 字 1.52s 1429ms 0.94 ← 短句最差
19 字 3.36s 1881ms 0.56
26 字 4.64s 2527ms 0.55
**注意短句 RTF 反而最差**:每次调用有约 1 秒的固定开销,
与文本长度无关(talker 模型自回归解码的启动成本 + 声码器解码)。
所以"让模型先说个短句"并不能降低首音延迟——反而比中长句更不划算。
流式可以绕开这个固定开销,但 qwen-tts 的封装把 codes→音频 的解码放在
全部 codes 生成完之后,拿不到增量。要走流式需要接管底层解码循环。
"""
from __future__ import annotations
import io
import json
import logging
import time
import urllib.error
import urllib.request
import wave
from typing import Iterator
import numpy as np
from .tts import split_sentences # 复用分句逻辑
log = logging.getLogger(__name__)
class RemoteTTSError(RuntimeError):
pass
class RemoteTTS:
"""通过 HTTP 调用远端 TTS 服务。接口与 LocalTTS 一致。"""
def __init__(self, base_url: str, *, voice: str = "Serena",
instructions: str = "用轻快活泼的语气说",
language: str = "Chinese", timeout_s: float = 60.0):
self.base_url = base_url.rstrip("/")
self.voice = voice
self.instructions = instructions
self.language = language
self.timeout_s = timeout_s
self.sample_rate = 24000 # Qwen3-TTS 输出 24kHz,稍后按需重采样
self._checked = False
# ------------------------------------------------------------ 健康检查
def health(self) -> tuple[bool, str]:
try:
with urllib.request.urlopen(f"{self.base_url}/health",
timeout=10) as r:
d = json.loads(r.read().decode())
if d.get("model_loaded"):
return True, f"模型已加载,默认音色 {d.get('default_voice')}"
return False, f"服务在但模型未加载:{d.get('status')}"
except Exception as e: # noqa: BLE001
return False, f"{type(e).__name__}: {e}"
def list_voices(self) -> dict:
with urllib.request.urlopen(f"{self.base_url}/v1/voices", timeout=10) as r:
return json.loads(r.read().decode())
# ------------------------------------------------------------ 合成
def synthesize(self, text: str, *, instructions: str | None = None) -> np.ndarray:
"""合成一段文本,返回 float32 单声道。"""
text = (text or "").strip()
if not text:
return np.zeros(0, dtype=np.float32)
payload = json.dumps({
"input": text,
"voice": self.voice,
"instructions": instructions if instructions is not None else self.instructions,
"language": self.language,
"response_format": "wav",
}).encode("utf-8")
req = urllib.request.Request(
f"{self.base_url}/v1/audio/speech",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
t0 = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=self.timeout_s) as r:
wav_bytes = r.read()
sr = int(r.headers.get("X-Sample-Rate", self.sample_rate))
gen_ms = r.headers.get("X-Gen-Ms", "?")
rtf = r.headers.get("X-RTF", "?")
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", "replace")[:200]
raise RemoteTTSError(f"TTS 服务 HTTP {e.code}: {detail}") from e
except Exception as e: # noqa: BLE001
raise RemoteTTSError(f"TTS 服务不可达:{type(e).__name__}: {e}") from e
self.sample_rate = sr
log.debug("合成 %d 字:服务端 %sms(RTF %s),往返 %.0fms",
len(text), gen_ms, rtf, (time.perf_counter() - t0) * 1000)
with wave.open(io.BytesIO(wav_bytes), "rb") as w:
n = w.getnframes()
ch = w.getnchannels()
pcm = w.readframes(n)
audio = np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
if ch > 1: # 服务端是单声道,防御一下
audio = audio.reshape(-1, ch).mean(axis=1)
return audio
def synthesize_sentences(self, text: str) -> Iterator[tuple[str, np.ndarray]]:
"""按句合成,逐句返回。调用方拿到第一句就能开播。"""
for seg in split_sentences(text):
yield seg, self.synthesize(seg)
def synthesize_all(self, text: str) -> np.ndarray:
chunks = [a for _, a in self.synthesize_sentences(text) if len(a)]
if not chunks:
return np.zeros(0, dtype=np.float32)
return np.concatenate(chunks)
__all__ = ["RemoteTTS", "RemoteTTSError"]