助手本体:会话引擎、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 恢复
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"""语音层:STT / TTS。
|
||||
|
||||
TTS 有两个后端,接口一致(synthesize / synthesize_sentences),
|
||||
切换只需改配置 AI_TTS_BACKEND:
|
||||
|
||||
local — sherpa-onnx vits-zh-ll,RTF 0.09,很快但音色用户不满意
|
||||
remote — Qwen3-TTS 独立服务,RTF 0.55–0.94,音质是另一档
|
||||
|
||||
为什么 remote 要走 HTTP 而不是进程内:qwen-tts 与助手/vllm-omni 的依赖互斥
|
||||
(transformers 4.57 vs 5.14、hf_hub 0.36 vs 1.31),同 venv 会互相破坏。
|
||||
|
||||
模型文件不随代码走(体积大、可重新下载),由 VoiceConfig.model_dir 指向。
|
||||
缺失时构造对象直接报错并提示下载脚本,不静默降级——静默降级会让
|
||||
"没声音"变成一个很难查的问题。
|
||||
"""
|
||||
|
||||
from .stt import STTError, STTStream, StreamingSTT
|
||||
from .tts import MAX_CHARS_PER_SEGMENT, LocalTTS, TTSError, split_sentences
|
||||
from .remote_tts import RemoteTTS, RemoteTTSError
|
||||
|
||||
|
||||
def create_tts(cfg):
|
||||
"""按配置造 TTS。返回对象统一提供 synthesize / synthesize_sentences。"""
|
||||
if cfg.tts_backend == "remote":
|
||||
return RemoteTTS(cfg.tts_service_url, voice=cfg.tts_speaker,
|
||||
instructions=cfg.tts_instructions,
|
||||
language=cfg.tts_language)
|
||||
return LocalTTS(cfg)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"StreamingSTT", "STTStream", "STTError",
|
||||
"LocalTTS", "TTSError", "split_sentences", "MAX_CHARS_PER_SEGMENT",
|
||||
"RemoteTTS", "RemoteTTSError", "create_tts",
|
||||
]
|
||||
@@ -0,0 +1,138 @@
|
||||
"""远端 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"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""本地流式 STT(sherpa-onnx Zipformer 中英双语)。
|
||||
|
||||
实测(P0-语音基准报告.md):
|
||||
|
||||
N5095(4核,无 AVX2) RTF 0.22 @2线程 / 0.86 @4线程
|
||||
服务器(i9-14900K) RTF 0.03
|
||||
|
||||
**线程数是实测出来的,不能按"核多给多"猜。** N5095 上 4 线程比 2 线程
|
||||
慢 3.9 倍(小算子多,每算子一次线程同步,线程越多同步开销越大)。
|
||||
所以线程数由配置决定,不写死。
|
||||
|
||||
另外两条实测结论写进了代码:
|
||||
|
||||
* **冲刷耗时 ≈ 0**:流式解码过程中已持续输出,`input_finished()` 后
|
||||
立即拿到最终结果。"说完到出结果"的开销在 VAD 判定,不在识别器。
|
||||
* **并非每块都解码**:约每 3 块解一次(模型 chunk 约 300ms)。
|
||||
所以单块耗时偶尔超过块长是正常的,判断能否实时要看 RTF 而不是单块。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
from ..config import VoiceConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class STTError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class StreamingSTT:
|
||||
"""流式识别器。线程安全地创建多条流(多客户端场景)。"""
|
||||
|
||||
def __init__(self, cfg: VoiceConfig):
|
||||
self.cfg = cfg
|
||||
d = Path(cfg.model_dir) / "stt"
|
||||
files = {
|
||||
"tokens": d / "tokens.txt",
|
||||
"encoder": d / "encoder-epoch-99-avg-1.int8.onnx",
|
||||
"decoder": d / "decoder-epoch-99-avg-1.int8.onnx",
|
||||
"joiner": d / "joiner-epoch-99-avg-1.int8.onnx",
|
||||
}
|
||||
missing = [k for k, p in files.items() if not p.is_file()]
|
||||
if missing:
|
||||
raise STTError(
|
||||
f"STT 模型文件缺失:{missing}(目录 {d})。"
|
||||
f"先跑 p0/stt_tts_bench/download_models.sh")
|
||||
|
||||
self._rec = sherpa_onnx.OnlineRecognizer.from_transducer(
|
||||
tokens=str(files["tokens"]),
|
||||
encoder=str(files["encoder"]),
|
||||
decoder=str(files["decoder"]),
|
||||
joiner=str(files["joiner"]),
|
||||
num_threads=cfg.stt_threads,
|
||||
sample_rate=cfg.sample_rate,
|
||||
feature_dim=80,
|
||||
decoding_method="greedy_search",
|
||||
# 端点检测留给上层的 VAD 处理:方案 §5 要求
|
||||
# "语音活动检测、说完判断、唤醒是不同环节",不合并。
|
||||
enable_endpoint_detection=False,
|
||||
)
|
||||
log.info("STT 就绪:%d 线程,采样率 %d", cfg.stt_threads, cfg.sample_rate)
|
||||
|
||||
def new_stream(self) -> "STTStream":
|
||||
return STTStream(self, self._rec.create_stream())
|
||||
|
||||
|
||||
class STTStream:
|
||||
"""一条识别流。"""
|
||||
|
||||
def __init__(self, owner: StreamingSTT, stream):
|
||||
self._owner = owner
|
||||
self._s = stream
|
||||
self._sr = owner.cfg.sample_rate
|
||||
self._consumed = 0
|
||||
|
||||
def accept(self, samples: np.ndarray) -> None:
|
||||
"""喂入 float32 单声道音频(取值 [-1,1])。"""
|
||||
if samples.dtype != np.float32:
|
||||
samples = samples.astype(np.float32)
|
||||
self._s.accept_waveform(self._sr, samples)
|
||||
self._consumed += len(samples)
|
||||
rec = self._owner._rec
|
||||
while rec.is_ready(self._s):
|
||||
rec.decode_stream(self._s)
|
||||
|
||||
def accept_pcm16(self, pcm: bytes) -> None:
|
||||
"""喂入 16bit PCM 字节流(麦克风常见格式)。"""
|
||||
self.accept(np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0)
|
||||
|
||||
@property
|
||||
def result(self) -> str:
|
||||
"""当前结果。流式过程中会不断变长,说完后即为最终结果。"""
|
||||
return self._owner._rec.get_result(self._s)
|
||||
|
||||
@property
|
||||
def consumed_seconds(self) -> float:
|
||||
"""已喂入的音频时长。用于判断"喂多少音频才出第一个字"(算法延迟)。"""
|
||||
return self._consumed / self._sr
|
||||
|
||||
def finish(self) -> str:
|
||||
"""结束输入并冲刷。
|
||||
|
||||
实测冲刷耗时 ≈ 0ms——流式解码已持续输出,这里不额外花钱。
|
||||
"""
|
||||
rec = self._owner._rec
|
||||
self._s.input_finished()
|
||||
while rec.is_ready(self._s):
|
||||
rec.decode_stream(self._s)
|
||||
return rec.get_result(self._s)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""丢弃当前流,重新开始(打断后重新听)。"""
|
||||
self._owner._rec.reset(self._s)
|
||||
self._consumed = 0
|
||||
|
||||
|
||||
__all__ = ["StreamingSTT", "STTStream", "STTError"]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""本地 TTS(sherpa-onnx VITS 中文)。
|
||||
|
||||
选型依据(P0-语音基准报告.md §六之二):
|
||||
|
||||
vits-zh-ll 服务器 RTF 0.09 / N5095 RTF 1.86 ← 用户试听选定,音质最好
|
||||
piper-huayan-med 服务器 0.36 / N5095 0.32 (音质次之)
|
||||
kokoro 1.96 以上,排除
|
||||
|
||||
**两个必须写进代码的实测结论:**
|
||||
|
||||
1. **VITS 是非自回归的,没有"边合成边播"。**
|
||||
实测回调只在合成结束后触发一次,所以首音延迟 == 整句合成耗时。
|
||||
提前出声的唯一手段是**按语义短句逐句合成**——把长回复切成短句,
|
||||
第一句合成完就能播。
|
||||
|
||||
2. **`max_num_sentences=1` 会静默截断多句输入。**
|
||||
传含多个句号的文本只会合成第一句,**不报错**。
|
||||
第一版测"长段"时只出 0.94 秒音频,差点当成正常结果。
|
||||
所以这里必须先分句,逐句送。
|
||||
|
||||
另外按方案 §5 要求,输出是 16kHz 单声道 float32;调用方负责重采样。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
from ..config import VoiceConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#: 句末标点。中英文都要,方案 §5 要求覆盖中英混读。
|
||||
_SENT_END = "。!?;!?;"
|
||||
#: 从句标点,长句再切一刀,避免单句合成太久
|
||||
_CLAUSE = ",,、::"
|
||||
|
||||
#: 单句最大字符数。超过就按从句标点再切——
|
||||
#: 句子越长,首音延迟越大(首音 == 整句合成耗时)。
|
||||
MAX_CHARS_PER_SEGMENT = 30
|
||||
|
||||
|
||||
class TTSError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def split_sentences(text: str, *, max_chars: int = MAX_CHARS_PER_SEGMENT) -> list[str]:
|
||||
"""按语义切分,返回可逐句合成的片段。
|
||||
|
||||
先按句末标点切,过长的再按从句标点切,仍过长就硬切。
|
||||
标点保留在片段末尾(TTS 需要它来判断语调)。
|
||||
|
||||
这个函数存在的原因是 `max_num_sentences=1` 会静默丢内容——
|
||||
不自己切,就会"念到一半没了"。
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return []
|
||||
|
||||
parts = re.split(rf"(?<=[{_SENT_END}])", text)
|
||||
out: list[str] = []
|
||||
for p in parts:
|
||||
p = p.strip()
|
||||
if not p:
|
||||
continue
|
||||
if len(p) <= max_chars:
|
||||
out.append(p)
|
||||
continue
|
||||
# 过长:按从句标点再切
|
||||
subs = re.split(rf"(?<=[{_CLAUSE}])", p)
|
||||
buf = ""
|
||||
for s in subs:
|
||||
if not s:
|
||||
continue
|
||||
if len(buf) + len(s) <= max_chars:
|
||||
buf += s
|
||||
else:
|
||||
if buf:
|
||||
out.append(buf)
|
||||
# 单从句仍然过长,硬切
|
||||
while len(s) > max_chars:
|
||||
out.append(s[:max_chars])
|
||||
s = s[max_chars:]
|
||||
buf = s
|
||||
if buf:
|
||||
out.append(buf)
|
||||
return out
|
||||
|
||||
|
||||
class LocalTTS:
|
||||
def __init__(self, cfg: VoiceConfig):
|
||||
self.cfg = cfg
|
||||
d = Path(cfg.model_dir) / "tts"
|
||||
root = Path(cfg.model_dir)
|
||||
files = {
|
||||
"model": d / "model.onnx",
|
||||
"lexicon": d / "lexicon.txt",
|
||||
"tokens": d / "tokens.txt",
|
||||
}
|
||||
missing = [k for k, p in files.items() if not p.is_file()]
|
||||
if missing:
|
||||
raise TTSError(
|
||||
f"TTS 模型文件缺失:{missing}(目录 {d})。"
|
||||
f"先跑 p0/stt_tts_bench/download_models.sh")
|
||||
|
||||
rule_fsts = ",".join(
|
||||
str(d / f) for f in ("date.fst", "number.fst", "phone.fst", "new_heteronym.fst")
|
||||
if (d / f).is_file())
|
||||
|
||||
self._tts = sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||
model=str(files["model"]),
|
||||
lexicon=str(files["lexicon"]),
|
||||
tokens=str(files["tokens"]),
|
||||
dict_dir=str(d / "dict"),
|
||||
),
|
||||
num_threads=cfg.tts_threads,
|
||||
provider="cpu",
|
||||
),
|
||||
rule_fsts=rule_fsts,
|
||||
# 逐句生成:这是唯一能提前出声的手段(见模块文档)
|
||||
max_num_sentences=1,
|
||||
))
|
||||
self.sample_rate = self._tts.sample_rate
|
||||
log.info("TTS 就绪:%d 线程,输出 %d Hz", cfg.tts_threads, self.sample_rate)
|
||||
|
||||
def synthesize(self, text: str) -> np.ndarray:
|
||||
"""合成一段文本(内部已确保是单句)。"""
|
||||
if not text.strip():
|
||||
return np.zeros(0, dtype=np.float32)
|
||||
audio = self._tts.generate(text, sid=0, speed=1.0)
|
||||
return np.asarray(audio.samples, dtype=np.float32)
|
||||
|
||||
def synthesize_sentences(self, text: str) -> Iterator[tuple[str, np.ndarray]]:
|
||||
"""按句合成,逐句 yield (句子, 音频)。
|
||||
|
||||
调用方拿到第一句就能开播,不必等整段——
|
||||
这正是 VITS 非自回归下唯一的低延迟路径。
|
||||
"""
|
||||
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__ = ["LocalTTS", "TTSError", "split_sentences", "MAX_CHARS_PER_SEGMENT"]
|
||||
Reference in New Issue
Block a user