Files
ai-companion/p0/stt_tts_bench/bench_concurrent.py
T
ylhan 35e107c290 助手本体:会话引擎、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 恢复
2026-09-15 11:05:09 +08:00

211 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()