助手本体:会话引擎、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:
ylhan
2026-09-15 11:05:09 +08:00
commit 35e107c290
46 changed files with 5976 additions and 0 deletions
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""P0 基准 · TTS→STT 往返测试。
作用有两个:
1. 验证 TTS 输出"可懂"——合成得再快,念出来听不懂也没用。
Piper 的 x_low 是最低音质档,且日志里有 Unknown token 警告,必须确认发出的音是正常的。
2. 给一个粗略的识别正确率信号(字错率 CER)。
重要局限:这是合成语音,比真人近距离录音干净得多,**会明显高估真实识别率**。
真实的远场识别率必须等麦克风阵列到货后用真人录音测。这里的结果只能当"下限的信号",
不能当验收数据。
"""
import os
import re
import wave
import numpy as np
import sherpa_onnx
HERE = os.path.dirname(os.path.abspath(__file__))
STT = os.path.join(HERE, "models", "stt")
CASES = [
"好的我记住了",
"今天北京晴转多云气温十八到二十六度",
"明天上午十点有个会议地点在三号会议室",
"订单编号是七三九一金额一千二百五十元",
"会议定在二零二六年九月十四日下午三点半",
]
def load_tts(which):
if which == "piper":
d = os.path.join(HERE, "models", "tts_piper_xlow")
vits = sherpa_onnx.OfflineTtsVitsModelConfig(
model=os.path.join(d, "zh_CN-huayan-x_low.onnx"),
lexicon=os.path.join(d, "lexicon.txt"),
tokens=os.path.join(d, "tokens.txt"))
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
model=sherpa_onnx.OfflineTtsModelConfig(vits=vits, num_threads=2, provider="cpu"),
max_num_sentences=1))
d = os.path.join(HERE, "models", "tts")
vits = sherpa_onnx.OfflineTtsVitsModelConfig(
model=os.path.join(d, "model.onnx"), lexicon=os.path.join(d, "lexicon.txt"),
tokens=os.path.join(d, "tokens.txt"), dict_dir=os.path.join(d, "dict"))
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
model=sherpa_onnx.OfflineTtsModelConfig(vits=vits, num_threads=2, provider="cpu"),
rule_fsts=",".join(os.path.join(d, f) for f in
("date.fst", "number.fst", "phone.fst", "new_heteronym.fst")),
max_num_sentences=1))
def load_stt():
return sherpa_onnx.OnlineRecognizer.from_transducer(
tokens=os.path.join(STT, "tokens.txt"),
encoder=os.path.join(STT, "encoder-epoch-99-avg-1.int8.onnx"),
decoder=os.path.join(STT, "decoder-epoch-99-avg-1.int8.onnx"),
joiner=os.path.join(STT, "joiner-epoch-99-avg-1.int8.onnx"),
num_threads=2, sample_rate=16000, feature_dim=80,
decoding_method="greedy_search", enable_endpoint_detection=False)
def transcribe(rec, samples):
s = rec.create_stream()
chunk = 1600
pos = 0
while pos < len(samples):
s.accept_waveform(16000, samples[pos:pos + chunk])
pos += chunk
while rec.is_ready(s):
rec.decode_stream(s)
s.accept_waveform(16000, np.zeros(16000, dtype=np.float32))
while rec.is_ready(s):
rec.decode_stream(s)
s.input_finished()
while rec.is_ready(s):
rec.decode_stream(s)
return rec.get_result(s)
def normalize(t):
"""只保留中文字符做比较,去掉空格与标点。"""
return re.sub(r"[^一-鿿]", "", t)
def cer(ref, hyp):
"""字错率:编辑距离 / 参考长度。"""
r, h = normalize(ref), normalize(hyp)
if not r:
return float("nan")
d = np.zeros((len(r) + 1, len(h) + 1), dtype=np.int32)
d[:, 0] = np.arange(len(r) + 1)
d[0, :] = np.arange(len(h) + 1)
for i in range(1, len(r) + 1):
for j in range(1, len(h) + 1):
d[i, j] = min(d[i - 1, j] + 1, d[i, j - 1] + 1,
d[i - 1, j - 1] + (r[i - 1] != h[j - 1]))
return d[len(r), len(h)] / len(r)
def main():
which = os.environ.get("TTS_MODEL", "piper")
tts = load_tts(which)
rec = load_stt()
print(f"TTS={which} → STT=zipformer-bilingual-zh-en(int8)\n")
cers = []
saved = 0
outdir = os.path.join(HERE, "roundtrip_wav")
os.makedirs(outdir, exist_ok=True)
for i, text in enumerate(CASES):
audio = tts.generate(text, sid=0, speed=1.0)
samples = np.array(audio.samples, dtype=np.float32)
hyp = transcribe(rec, samples)
c = cer(text, hyp)
cers.append(c)
mark = "✅" if c == 0 else "△" if c < 0.2 else "❌"
print(f"{mark} 原文: {text}")
print(f" 识别: {hyp} CER={c:.2f}")
# 存一份,便于人工听一下 TTS 到底念得对不对
pcm = (np.clip(samples, -1, 1) * 32767).astype(np.int16)
with wave.open(os.path.join(outdir, f"{i}_{which}.wav"), "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(audio.sample_rate)
w.writeframes(pcm.tobytes())
saved += 1
good = sum(1 for c in cers if c == 0)
print(f"\n完全正确 {good}/{len(CASES)},平均 CER={np.mean(cers):.3f}")
print(f"合成音频已存到 {outdir}/(可人工试听确认音质)")
print("\n⚠ 合成语音远比真人远场录音干净,此结果不能作为识别率验收数据。")
if __name__ == "__main__":
main()