从 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 恢复
211 lines
8.4 KiB
Python
211 lines
8.4 KiB
Python
#!/usr/bin/env python3
|
||
"""P0 基准 · STT 与 TTS 并发。
|
||
|
||
为什么必须测:
|
||
|
||
真实系统里助手说话时麦克风并没有关——要检测打断,STT 必须同时在跑。
|
||
这台机器只有 4 个核,且 bench_threads.py 已经证明 STT 超过 2 线程就会急剧退化。
|
||
那么"STT(2线程) + TTS(2线程)"极可能互相踩踏。
|
||
|
||
本脚本把两种负载跑在一起,和各自单独跑的结果对比,量化退化幅度。
|
||
只看单独指标会得出"两个都能实时"的错误结论。
|
||
"""
|
||
|
||
import os
|
||
import statistics
|
||
import threading
|
||
import time
|
||
import wave
|
||
|
||
import numpy as np
|
||
import sherpa_onnx
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
STT = os.path.join(HERE, "models", "stt")
|
||
WAVS = os.path.join(HERE, "wavs")
|
||
SR = 16000
|
||
CHUNK = int(SR * 0.1)
|
||
|
||
TTS_SENTENCES = [
|
||
"我查了一下,明天上午十点有个会议。",
|
||
"地点在三号会议室,需要提前十分钟到。",
|
||
"另外提醒你,后天是妈妈的生日。",
|
||
"要不要我提前帮你订一束花?",
|
||
"还有你上周说要买的那本书现在有货了。",
|
||
"价格是四十九块八,要不要下单?",
|
||
]
|
||
|
||
|
||
def read_wav(p):
|
||
with wave.open(p, "rb") as w:
|
||
return np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
|
||
|
||
|
||
def build_stt(nt):
|
||
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=nt, sample_rate=SR, feature_dim=80,
|
||
decoding_method="greedy_search", enable_endpoint_detection=False)
|
||
|
||
|
||
def build_tts(which, nt):
|
||
if which == "matcha":
|
||
d = os.path.join(HERE, "models", "matcha")
|
||
matcha = sherpa_onnx.OfflineTtsMatchaModelConfig(
|
||
acoustic_model=os.path.join(d, "model-steps-3.onnx"),
|
||
vocoder=os.path.join(HERE, "models", "matcha_vocoder", "hifigan_v2.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(matcha=matcha, num_threads=nt, provider="cpu"),
|
||
rule_fsts=",".join(os.path.join(d, f) for f in ("date.fst", "number.fst", "phone.fst")),
|
||
max_num_sentences=1))
|
||
if which == "kokoro_int8":
|
||
d = os.path.join(HERE, "models", "kokoro_int8")
|
||
kokoro = sherpa_onnx.OfflineTtsKokoroModelConfig(
|
||
model=os.path.join(d, "model.int8.onnx"),
|
||
voices=os.path.join(d, "voices.bin"),
|
||
tokens=os.path.join(d, "tokens.txt"),
|
||
data_dir=os.path.join(d, "espeak-ng-data"),
|
||
dict_dir=os.path.join(d, "dict"),
|
||
lexicon=",".join(os.path.join(d, f) for f in
|
||
("lexicon-us-en.txt", "lexicon-gb-en.txt", "lexicon-zh.txt")))
|
||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||
model=sherpa_onnx.OfflineTtsModelConfig(kokoro=kokoro, num_threads=nt, provider="cpu"),
|
||
max_num_sentences=1))
|
||
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=nt, 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=nt, 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 stt_pass(rec, samples):
|
||
"""喂一遍音频,返回 (rtf, 单块解码耗时列表)。"""
|
||
s = rec.create_stream()
|
||
durs = []
|
||
pos = 0
|
||
t_all = time.perf_counter()
|
||
while pos < len(samples):
|
||
block = samples[pos:pos + CHUNK]
|
||
pos += CHUNK
|
||
t0 = time.perf_counter()
|
||
s.accept_waveform(SR, block)
|
||
while rec.is_ready(s):
|
||
rec.decode_stream(s)
|
||
durs.append(time.perf_counter() - t0)
|
||
total = time.perf_counter() - t_all
|
||
return total / (len(samples) / SR), durs
|
||
|
||
|
||
def tts_pass(tts, sents):
|
||
"""连续合成若干句,返回 (rtf, 每句耗时ms)。"""
|
||
per = []
|
||
audio_s = 0.0
|
||
t_all = time.perf_counter()
|
||
for s in sents:
|
||
t0 = time.perf_counter()
|
||
a = tts.generate(s, sid=0, speed=1.0)
|
||
per.append((time.perf_counter() - t0) * 1000)
|
||
audio_s += len(a.samples) / a.sample_rate
|
||
total = time.perf_counter() - t_all
|
||
return total / audio_s, per
|
||
|
||
|
||
def main():
|
||
stt_nt = int(os.environ.get("STT_THREADS", "2"))
|
||
tts_nt = int(os.environ.get("TTS_THREADS", "2"))
|
||
tts_kind = os.environ.get("TTS_MODEL", "piper")
|
||
wav = read_wav(os.path.join(WAVS, "stt_0.wav"))
|
||
dur = len(wav) / SR
|
||
|
||
print(f"音频 {dur:.2f}s STT线程={stt_nt} TTS={tts_kind}线程={tts_nt}\n")
|
||
|
||
stt = build_stt(stt_nt)
|
||
tts = build_tts(tts_kind, tts_nt)
|
||
stt_pass(stt, wav[: SR * 2]) # 预热
|
||
tts.generate("预热", sid=0, speed=1.0)
|
||
|
||
# 单独
|
||
solo_stt, _ = stt_pass(stt, wav)
|
||
solo_tts, _ = tts_pass(tts, TTS_SENTENCES)
|
||
print(f"单独: STT RTF={solo_stt:.2f} TTS RTF={solo_tts:.2f}")
|
||
|
||
# 并发:TTS 在后台线程持续合成,主线程跑 STT
|
||
stop = threading.Event()
|
||
tts_stats = {"runs": 0, "rtf": [], "done": False}
|
||
|
||
def tts_worker():
|
||
try:
|
||
while not stop.is_set():
|
||
rtf, _ = tts_pass(tts, TTS_SENTENCES)
|
||
tts_stats["rtf"].append(rtf)
|
||
tts_stats["runs"] += 1
|
||
if tts_stats["runs"] >= 3:
|
||
break
|
||
finally:
|
||
tts_stats["done"] = True
|
||
|
||
th = threading.Thread(target=tts_worker)
|
||
th.start()
|
||
time.sleep(0.2) # 让 TTS 先跑起来,制造真实争抢
|
||
conc_rtfs, conc_durs = [], []
|
||
for _ in range(3):
|
||
r, d = stt_pass(stt, wav)
|
||
conc_rtfs.append(r)
|
||
conc_durs.extend(d)
|
||
stop.set()
|
||
th.join()
|
||
|
||
conc_tts = statistics.median(tts_stats["rtf"]) if tts_stats["rtf"] else float("nan")
|
||
stt_med = statistics.median(conc_rtfs)
|
||
ratios = sorted(x / (CHUNK / SR) for x in conc_durs)
|
||
|
||
print(f"并发: STT RTF={stt_med:.2f} (×{stt_med/solo_stt:.2f}) "
|
||
f"TTS RTF={conc_tts:.2f} (×{conc_tts/solo_tts:.2f})")
|
||
print(f" 并发下单块解码 p50={statistics.median(ratios):.2f} "
|
||
f"p95={ratios[int(len(ratios)*0.95)]:.2f} max={ratios[-1]:.2f}(>1 表示积压)")
|
||
|
||
# 判定口径:看 RTF(聚合力,<1 表示跟得上)。单块 p95 只作观察项——
|
||
# 流式模型并非每块都解码(实测约每 3 块解一次),所以单块 >1 不等于积压。
|
||
# 留 0.7 的余量,因为还要给视觉检测等其它负载留 CPU。
|
||
if stt_med < 0.7 and conc_tts < 0.7:
|
||
verdict = f"✅ 并发可实时(STT 余量 {1/stt_med:.1f}x,TTS 余量 {1/conc_tts:.1f}x)"
|
||
elif stt_med < 1 and conc_tts < 1:
|
||
verdict = "△ 并发勉强够用,视觉检测再加负载有风险"
|
||
else:
|
||
verdict = "⚠ 并发跟不上实时"
|
||
print(f"\n判定: {verdict}")
|
||
|
||
import json
|
||
out = os.path.join(HERE, f"results_concurrent_{tts_kind}.json")
|
||
with open(out, "w") as f:
|
||
json.dump({"stt_threads": stt_nt, "tts_threads": tts_nt, "tts_model": tts_kind,
|
||
"solo_stt_rtf": solo_stt, "solo_tts_rtf": solo_tts,
|
||
"conc_stt_rtf": stt_med, "conc_tts_rtf": conc_tts,
|
||
"stt_degrade": stt_med / solo_stt, "tts_degrade": conc_tts / solo_tts,
|
||
"conc_chunk_p95": ratios[int(len(ratios) * 0.95)],
|
||
"conc_chunk_max": ratios[-1]}, f, ensure_ascii=False, indent=2)
|
||
print(f"结果已写入 {out}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|