助手本体:会话引擎、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
+125
View File
@@ -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"]