从 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 恢复
139 lines
5.4 KiB
Python
139 lines
5.4 KiB
Python
"""远端 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"]
|