助手本体:会话引擎、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,210 @@
|
||||
#!/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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0 基准 · 本地流式 STT(sherpa-onnx 中英双语 Zipformer)。
|
||||
|
||||
测什么、为什么:
|
||||
|
||||
流式识别有两种"慢",必须分开测,混在一起会得出错误结论。
|
||||
|
||||
1. 算法延迟(first_partial_audio_s):要喂进多少秒音频,才吐出第一个字。
|
||||
这是模型属性(chunk 大小 + lookahead),换机器不变。它决定了"用户说完一个字到屏幕上出现字"的下限。
|
||||
|
||||
2. 算力延迟(rtf / chunk_ratio):解码耗时与音频时长的比。
|
||||
RTF<1 表示平均能跟上实时;但平均值会骗人——只要 p95 的单块解码时间超过块的时长,
|
||||
实时场景下就会累积积压,越说越滞后。所以必须同时看 chunk_ratio 的分位数。
|
||||
|
||||
3. 收尾延迟(finalize_ms):音频喂完后多久能拿到最终结果。
|
||||
这一段直接计入方案 P1"结束到首段有声回复"的 2 秒预算。
|
||||
|
||||
用法:
|
||||
python3 bench_stt.py # 默认线程 1,2,4
|
||||
THREADS=2 python3 bench_stt.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
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")
|
||||
SAMPLE_RATE = 16000
|
||||
CHUNK_MS = int(os.environ.get("CHUNK_MS", "100"))
|
||||
TAIL_SILENCE_S = 1.0 # 喂一段静音,让模型吐出最后一个字(真实系统里靠 VAD 判定说完)
|
||||
|
||||
|
||||
def read_wav(path):
|
||||
with wave.open(path, "rb") as w:
|
||||
assert w.getnchannels() == 1, f"{path} 不是单声道"
|
||||
assert w.getframerate() == SAMPLE_RATE, f"{path} 采样率 {w.getframerate()} != {SAMPLE_RATE}"
|
||||
pcm = w.readframes(w.getnframes())
|
||||
return np.frombuffer(pcm, dtype=np.int16).astype(np.float32) / 32768.0
|
||||
|
||||
|
||||
def build_recognizer(num_threads):
|
||||
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=num_threads,
|
||||
sample_rate=SAMPLE_RATE,
|
||||
feature_dim=80,
|
||||
decoding_method="greedy_search",
|
||||
enable_endpoint_detection=False,
|
||||
)
|
||||
|
||||
|
||||
def run_one(rec, samples):
|
||||
"""喂一遍音频,返回各项指标。"""
|
||||
chunk = int(SAMPLE_RATE * CHUNK_MS / 1000)
|
||||
stream = rec.create_stream()
|
||||
decode_times = []
|
||||
first_partial_audio_s = None
|
||||
partials = []
|
||||
|
||||
t_all = time.perf_counter()
|
||||
pos = 0
|
||||
while pos < len(samples):
|
||||
block = samples[pos:pos + chunk]
|
||||
pos += len(block)
|
||||
t0 = time.perf_counter()
|
||||
stream.accept_waveform(SAMPLE_RATE, block)
|
||||
while rec.is_ready(stream):
|
||||
rec.decode_stream(stream)
|
||||
decode_times.append(time.perf_counter() - t0)
|
||||
txt = rec.get_result(stream)
|
||||
if txt:
|
||||
partials.append((pos / SAMPLE_RATE, txt))
|
||||
if first_partial_audio_s is None:
|
||||
first_partial_audio_s = pos / SAMPLE_RATE
|
||||
|
||||
audio_s = len(samples) / SAMPLE_RATE
|
||||
|
||||
# 收尾拆成两段测,混在一起会高估"说完到出结果"的开销:
|
||||
# tail_decode_ms —— 解码尾静音的耗时。真实系统里这段对应 VAD 判定说完的等待,
|
||||
# 是"必有开销",但与识别器算力无关,不能算进识别延迟。
|
||||
# flush_ms —— input_finished() 之后冲刷出最终结果的耗时。这才是纯识别器开销。
|
||||
t0 = time.perf_counter()
|
||||
tail = np.zeros(int(SAMPLE_RATE * TAIL_SILENCE_S), dtype=np.float32)
|
||||
pos = 0
|
||||
while pos < len(tail):
|
||||
block = tail[pos:pos + chunk]
|
||||
pos += len(block)
|
||||
stream.accept_waveform(SAMPLE_RATE, block)
|
||||
while rec.is_ready(stream):
|
||||
rec.decode_stream(stream)
|
||||
tail_decode_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
t1 = time.perf_counter()
|
||||
stream.input_finished()
|
||||
while rec.is_ready(stream):
|
||||
rec.decode_stream(stream)
|
||||
flush_ms = (time.perf_counter() - t1) * 1000
|
||||
final_text = rec.get_result(stream)
|
||||
total_s = time.perf_counter() - t_all
|
||||
|
||||
ratios = [d / (CHUNK_MS / 1000) for d in decode_times]
|
||||
ratios_sorted = sorted(ratios)
|
||||
|
||||
def pct(xs, p):
|
||||
if not xs:
|
||||
return float("nan")
|
||||
return xs[min(len(xs) - 1, int(len(xs) * p))]
|
||||
|
||||
return {
|
||||
"audio_s": audio_s,
|
||||
"total_s": total_s,
|
||||
"rtf": total_s / (audio_s + TAIL_SILENCE_S),
|
||||
"rtf_speech_only": total_s / audio_s,
|
||||
"chunk_ratio_p50": statistics.median(ratios) if ratios else float("nan"),
|
||||
"chunk_ratio_p95": pct(ratios_sorted, 0.95),
|
||||
"chunk_ratio_max": max(ratios) if ratios else float("nan"),
|
||||
"first_partial_audio_s": first_partial_audio_s,
|
||||
"tail_decode_ms": tail_decode_ms,
|
||||
"flush_ms": flush_ms,
|
||||
"final_text": final_text,
|
||||
"n_partials": len(partials),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
threads_list = [int(x) for x in os.environ.get("THREADS", "1,2,4").split(",")]
|
||||
wav_files = sorted(f for f in os.listdir(WAVS) if f.endswith(".wav"))
|
||||
if not wav_files:
|
||||
sys.exit(f"没有测试音频:{WAVS}")
|
||||
|
||||
print(f"chunk={CHUNK_MS}ms 尾静音={TAIL_SILENCE_S}s 音频={wav_files}")
|
||||
print(f"CPU: 4 核 N5095(无 AVX2)\n")
|
||||
|
||||
all_results = {}
|
||||
for nt in threads_list:
|
||||
print(f"=== num_threads={nt} ===")
|
||||
rec = build_recognizer(nt)
|
||||
# 预热:首次解码包含图优化/内存分配,不计入
|
||||
warm = np.zeros(SAMPLE_RATE, dtype=np.float32)
|
||||
s = rec.create_stream()
|
||||
s.accept_waveform(SAMPLE_RATE, warm)
|
||||
while rec.is_ready(s):
|
||||
rec.decode_stream(s)
|
||||
|
||||
rows = []
|
||||
for wf in wav_files:
|
||||
samples = read_wav(os.path.join(WAVS, wf))
|
||||
r = run_one(rec, samples)
|
||||
rows.append((wf, r))
|
||||
print(f" {wf:<9} 音频{r['audio_s']:5.2f}s RTF={r['rtf']:5.2f} "
|
||||
f"块比 p50={r['chunk_ratio_p50']:.2f} p95={r['chunk_ratio_p95']:.2f} "
|
||||
f"max={r['chunk_ratio_max']:.2f} 首字@{r['first_partial_audio_s'] or float('nan'):.2f}s "
|
||||
f"冲刷={r['flush_ms']:.0f}ms")
|
||||
print(f" 识别: {r['final_text']}")
|
||||
|
||||
rtfs = [r["rtf"] for _, r in rows]
|
||||
p95s = [r["chunk_ratio_p95"] for _, r in rows]
|
||||
fin = [r["flush_ms"] for _, r in rows]
|
||||
print(f" → RTF 均值={statistics.mean(rtfs):.2f} 最差={max(rtfs):.2f} | "
|
||||
f"块比p95 最差={max(p95s):.2f} | 纯冲刷 P50={statistics.median(fin):.0f}ms "
|
||||
f"最差={max(fin):.0f}ms")
|
||||
verdict = ("可实时" if max(rtfs) < 1 and max(p95s) < 1
|
||||
else "⚠ 跟不上实时" if max(rtfs) >= 1
|
||||
else "⚠ 平均够但存在突发积压风险")
|
||||
print(f" → 判定: {verdict}\n")
|
||||
all_results[nt] = {"rows": {w: r for w, r in rows}, "verdict": verdict}
|
||||
|
||||
out = os.path.join(HERE, "results_stt.json")
|
||||
with open(out, "w") as f:
|
||||
json.dump({"chunk_ms": CHUNK_MS, "results": all_results}, f,
|
||||
ensure_ascii=False, indent=2)
|
||||
print(f"结果已写入 {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""线程数扫描:查清 N5095 上 num_threads 的真实影响。
|
||||
|
||||
bench_stt.py 里 2 线程 RTF=0.22、4 线程 RTF=0.87,性能反而掉了 4 倍。
|
||||
本脚本对 1/2/3/4 线程各重复多轮,排除预热与顺序干扰,确认是否为真实现象。
|
||||
"""
|
||||
|
||||
import os
|
||||
import statistics
|
||||
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)
|
||||
REPEATS = int(os.environ.get("REPEATS", "5"))
|
||||
|
||||
|
||||
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(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 decode_once(rec, samples):
|
||||
s = rec.create_stream()
|
||||
t0 = time.perf_counter()
|
||||
pos = 0
|
||||
while pos < len(samples):
|
||||
s.accept_waveform(SR, samples[pos:pos + CHUNK])
|
||||
pos += CHUNK
|
||||
while rec.is_ready(s):
|
||||
rec.decode_stream(s)
|
||||
return time.perf_counter() - t0
|
||||
|
||||
|
||||
def main():
|
||||
wav = read_wav(os.path.join(WAVS, "stt_0.wav"))
|
||||
dur = len(wav) / SR
|
||||
print(f"音频 {dur:.2f}s,每档重复 {REPEATS} 轮(含 1 轮预热,不计入)\n")
|
||||
print(f"{'线程':>4} {'RTF中位':>9} {'RTF最差':>9} {'各轮RTF':>34}")
|
||||
print("-" * 62)
|
||||
for nt in (1, 2, 3, 4):
|
||||
rec = build(nt)
|
||||
decode_once(rec, wav[: SR * 2]) # 预热
|
||||
rtfs = []
|
||||
for _ in range(REPEATS):
|
||||
rtfs.append(decode_once(rec, wav) / dur)
|
||||
shown = " ".join(f"{r:.2f}" for r in rtfs)
|
||||
print(f"{nt:>4} {statistics.median(rtfs):>9.2f} {max(rtfs):>9.2f} {shown:>34}")
|
||||
del rec
|
||||
|
||||
print("\n=== 参照:系统负载 ===")
|
||||
print(open("/proc/loadavg").read().strip())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0 基准 · 本地 TTS(sherpa-onnx VITS 中文 zh-ll)。
|
||||
|
||||
测什么、为什么:
|
||||
|
||||
1. RTF:合成耗时 / 音频时长。<1 表示比实时快。
|
||||
2. 首音延迟:从调用到吐出第一块音频的耗时。这一段直接计入方案 P1 的 2 秒预算。
|
||||
3. 分句接续停顿:方案要求"按语义短句合成并播放"。逐句生成时,
|
||||
如果下一句还没合成完、上一句已经播完,喇叭里就会出现空档。
|
||||
这里用"生成耗时 vs 播放耗时"的竞速来预测停顿,不需要声卡。
|
||||
|
||||
用法:
|
||||
python3 bench_tts.py
|
||||
THREADS=1,2,4 python3 bench_tts.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import statistics
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TTS = os.path.join(HERE, "models", "tts")
|
||||
RULE_FSTS = ",".join(os.path.join(TTS, f) for f in
|
||||
("date.fst", "number.fst", "phone.fst", "new_heteronym.fst"))
|
||||
|
||||
SHORT = "好的,我记住了。"
|
||||
MEDIUM = "今天北京晴转多云,气温十八到二十六度,出门记得带件外套。"
|
||||
LONG = ("我查了一下,明天上午十点有个会议,地点在三号会议室。"
|
||||
"另外提醒你,后天是妈妈的生日,要不要我提前帮你订一束花?"
|
||||
"还有,你上周说要买的那本书,现在有货了,价格是四十九块八。")
|
||||
|
||||
# 方案 §5:数字、日期、中英混读都要覆盖
|
||||
CASES = [
|
||||
("短句", SHORT),
|
||||
("中句", MEDIUM),
|
||||
("长段", LONG),
|
||||
("数字", "订单编号是七三九一,金额一千二百五十元六角。"),
|
||||
("日期", "会议定在二零二六年九月十四日下午三点半。"),
|
||||
("中英混读", "这个 API 的 response time 大概在 200 毫秒左右。"),
|
||||
]
|
||||
|
||||
|
||||
def build_tts(num_threads):
|
||||
cfg = sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
|
||||
model=os.path.join(TTS, "model.onnx"),
|
||||
lexicon=os.path.join(TTS, "lexicon.txt"),
|
||||
tokens=os.path.join(TTS, "tokens.txt"),
|
||||
dict_dir=os.path.join(TTS, "dict"),
|
||||
),
|
||||
num_threads=num_threads,
|
||||
provider="cpu",
|
||||
),
|
||||
rule_fsts=RULE_FSTS,
|
||||
max_num_sentences=1, # 逐句生成,便于尽早开播
|
||||
)
|
||||
return sherpa_onnx.OfflineTts(cfg)
|
||||
|
||||
|
||||
def synth(tts, text):
|
||||
"""合成一段,返回 (首音延迟ms, 总耗时ms, 音频秒数, 音频)。"""
|
||||
chunks = []
|
||||
first_at = [None]
|
||||
t0 = time.perf_counter()
|
||||
|
||||
def cb(*args): # 兼容 (samples,) 与 (samples, progress) 两种签名
|
||||
samples = args[0]
|
||||
if first_at[0] is None:
|
||||
first_at[0] = (time.perf_counter() - t0) * 1000
|
||||
chunks.append(np.array(samples, dtype=np.float32, copy=True))
|
||||
return 0
|
||||
|
||||
audio = tts.generate(text, sid=0, speed=1.0, callback=cb)
|
||||
total_ms = (time.perf_counter() - t0) * 1000
|
||||
if first_at[0] is None:
|
||||
first_at[0] = total_ms
|
||||
dur_s = len(audio.samples) / audio.sample_rate
|
||||
return first_at[0], total_ms, dur_s, np.array(audio.samples)
|
||||
|
||||
|
||||
def split_sentences(text):
|
||||
"""按中英文标点切句,模拟"按语义短句合成"。"""
|
||||
parts = re.split(r"(?<=[。!?;!?;])", text)
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def main():
|
||||
threads_list = [int(x) for x in os.environ.get("THREADS", "1,2,4").split(",")]
|
||||
print(f"TTS: VITS zh-ll 线程扫描={threads_list} max_num_sentences=1\n")
|
||||
|
||||
all_results = {}
|
||||
for nt in threads_list:
|
||||
print(f"=== num_threads={nt} ===")
|
||||
tts = build_tts(nt)
|
||||
tts.generate("预热", sid=0, speed=1.0) # 预热
|
||||
|
||||
rows = {}
|
||||
for label, text in CASES:
|
||||
first_ms, total_ms, dur_s, _ = synth(tts, text)
|
||||
rtf = (total_ms / 1000) / dur_s
|
||||
rows[label] = {"first_ms": first_ms, "total_ms": total_ms,
|
||||
"dur_s": dur_s, "rtf": rtf, "text": text}
|
||||
print(f" {label:<8} 音频{dur_s:5.2f}s 合成{total_ms:7.0f}ms "
|
||||
f"RTF={rtf:5.2f} 首音={first_ms:6.0f}ms")
|
||||
|
||||
# 分句接续:完全串行生成,看下一句能否在上一句播完前备好
|
||||
print(f" --- 分句接续(长段切 {len(split_sentences(LONG))} 句)---")
|
||||
sents = split_sentences(LONG)
|
||||
t_gen = 0.0 # 该句开始生成的时刻(相对)
|
||||
play_end = 0.0 # 上一句播完的时刻
|
||||
timeline = []
|
||||
for i, s in enumerate(sents):
|
||||
first_ms, total_ms, dur_s, _ = synth(tts, s)
|
||||
gen_start = t_gen
|
||||
first_ready = gen_start + first_ms / 1000
|
||||
play_start = max(play_end, first_ready)
|
||||
gap = play_start - play_end if i > 0 else 0.0
|
||||
play_end = play_start + dur_s
|
||||
t_gen = gen_start + total_ms / 1000 # 串行:本句合成完才开始下一句
|
||||
timeline.append({"i": i, "text": s[:18], "dur_s": dur_s,
|
||||
"gen_ms": total_ms, "first_ms": first_ms,
|
||||
"gap_s": gap, "late": play_start - first_ready})
|
||||
flag = "空档" if gap > 0.02 else "OK"
|
||||
print(f" 句{i+1}: 播{dur_s:4.2f}s 合成{total_ms:6.0f}ms "
|
||||
f"首音{first_ms:5.0f}ms 空档={gap:5.2f}s [{flag}] {s[:16]}")
|
||||
max_gap = max(t["gap_s"] for t in timeline)
|
||||
total_audio = sum(t["dur_s"] for t in timeline)
|
||||
rows["_分句接续"] = {"max_gap_s": max_gap, "total_audio_s": total_audio,
|
||||
"timeline": timeline,
|
||||
"total_gen_ms": sum(t["gen_ms"] for t in timeline)}
|
||||
verdict = "无空档" if max_gap <= 0.02 else f"⚠ 最大空档 {max_gap:.2f}s"
|
||||
print(f" → 分句判定: {verdict}(总音频 {total_audio:.2f}s,"
|
||||
f"总合成 {sum(t['gen_ms'] for t in timeline):.0f}ms)\n")
|
||||
all_results[nt] = rows
|
||||
|
||||
out = os.path.join(HERE, "results_tts.json")
|
||||
with open(out, "w") as f:
|
||||
json.dump(all_results, f, ensure_ascii=False, indent=2)
|
||||
print(f"结果已写入 {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
"""P0 基准 · 本地 TTS 模型横向对比(N5095)。
|
||||
|
||||
背景:vits-zh-ll 实测 RTF≈1.7,慢于实时,且 VITS 非自回归——
|
||||
回调只在合成结束后触发一次,首音延迟 == 总合成耗时。
|
||||
本脚本用同一组句子对比多个候选模型,找是否有能跑到 RTF<0.5 的。
|
||||
|
||||
注意 max_num_sentences=1 会把多句输入截断为第一句(见 bench_tts.py 的坑),
|
||||
所以这里每句单独调用——这也正是真实系统的做法(按语义短句合成)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import statistics
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import sherpa_onnx
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
SHORT = "好的,我记住了。"
|
||||
MED = "今天北京晴转多云,气温十八到二十六度。"
|
||||
LONG = "我查了一下,明天上午十点有个会议,地点在三号会议室。"
|
||||
NUM = "订单编号是七三九一,金额一千二百五十元六角。"
|
||||
|
||||
TESTS = [("短句", SHORT), ("中句", MED), ("长句", LONG), ("数字", NUM)]
|
||||
|
||||
|
||||
def load_vits_zh_ll(nt):
|
||||
d = os.path.join(HERE, "models", "tts")
|
||||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||
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"),
|
||||
),
|
||||
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 load_piper_xlow(nt):
|
||||
d = os.path.join(HERE, "models", "tts_piper_xlow")
|
||||
return sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
|
||||
model=sherpa_onnx.OfflineTtsModelConfig(
|
||||
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"),
|
||||
),
|
||||
num_threads=nt, provider="cpu"),
|
||||
max_num_sentences=1))
|
||||
|
||||
|
||||
MODELS = [
|
||||
("vits-zh-ll", load_vits_zh_ll, "VITS 中文,115MB"),
|
||||
("piper-huayan-x_low", load_piper_xlow, "Piper 中文,20MB"),
|
||||
]
|
||||
|
||||
|
||||
def measure(tts, text):
|
||||
calls = [0]
|
||||
|
||||
def cb(*args):
|
||||
calls[0] += 1
|
||||
return 0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
audio = tts.generate(text, sid=0, speed=1.0, callback=cb)
|
||||
total_ms = (time.perf_counter() - t0) * 1000
|
||||
dur = len(audio.samples) / audio.sample_rate
|
||||
return total_ms, dur, calls[0]
|
||||
|
||||
|
||||
def main():
|
||||
nt = int(os.environ.get("THREADS", "2"))
|
||||
print(f"N5095 / num_threads={nt} / 每句单独合成\n")
|
||||
|
||||
report = {}
|
||||
for name, loader, desc in MODELS:
|
||||
try:
|
||||
tts = loader(nt)
|
||||
except Exception as e:
|
||||
print(f"=== {name} ({desc}) ===\n 加载失败: {type(e).__name__}: {e}\n")
|
||||
continue
|
||||
tts.generate("预热", sid=0, speed=1.0)
|
||||
print(f"=== {name} ({desc}) ===")
|
||||
rows = []
|
||||
for label, text in TESTS:
|
||||
total_ms, dur, ncalls = measure(tts, text)
|
||||
rtf = (total_ms / 1000) / dur
|
||||
rows.append((label, total_ms, dur, rtf, ncalls))
|
||||
print(f" {label:<5} {len(text):>2}字 → 音频{dur:5.2f}s 合成{total_ms:7.0f}ms "
|
||||
f"RTF={rtf:5.2f} 回调{ncalls}次")
|
||||
rtfs = [r[3] for r in rows]
|
||||
avg = statistics.mean(rtfs)
|
||||
print(f" → RTF 均值={avg:.2f} 最差={max(rtfs):.2f} "
|
||||
f"判定: {'✅ 可实时' if avg < 0.5 else '⚠ 慢于实时' if avg >= 1 else '△ 勉强'}\n")
|
||||
report[name] = {"desc": desc, "rows": rows, "rtf_avg": avg, "rtf_max": max(rtfs)}
|
||||
del tts
|
||||
|
||||
# 播放侧模拟:连续说 5 句时,下一句能否在上一句播完前备好
|
||||
print("=== 连续播放模拟(5 句,串行合成)===")
|
||||
loaders = {m[0]: m[1] for m in MODELS}
|
||||
for name in loaders:
|
||||
if name not in report:
|
||||
continue
|
||||
tts = loaders[name](nt)
|
||||
sents = ["我查了一下,明天上午十点有个会议。", "地点在三号会议室。",
|
||||
"另外提醒你,后天是妈妈的生日。", "要不要我提前帮你订一束花?",
|
||||
"还有你上周说要买的那本书现在有货了。"]
|
||||
t_gen = 0.0
|
||||
play_end = 0.0
|
||||
gaps = []
|
||||
for i, s in enumerate(sents):
|
||||
total_ms, dur, _ = measure(tts, s)
|
||||
first_ready = t_gen + total_ms / 1000
|
||||
play_start = max(play_end, first_ready)
|
||||
gaps.append(max(0.0, play_start - play_end))
|
||||
play_end = play_start + dur
|
||||
t_gen += total_ms / 1000
|
||||
print(f" {name:<22} 各句空档: {[f'{g:.1f}s' for g in gaps]} "
|
||||
f"最大={max(gaps):.2f}s 总播完={play_end:.1f}s")
|
||||
report[name]["gaps"] = gaps
|
||||
del tts
|
||||
print()
|
||||
|
||||
import json
|
||||
out = os.path.join(HERE, "results_tts_compare.json")
|
||||
with open(out, "w") as f:
|
||||
json.dump(report, f, ensure_ascii=False, indent=2)
|
||||
print(f"结果已写入 {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
# 下载 P0 基准所需模型。走 hf-mirror.com(本机 huggingface.co 直连不通)。
|
||||
# 可重复执行:已存在且大小正确的文件会跳过。
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
MIRROR="${HF_MIRROR:-https://hf-mirror.com}"
|
||||
STT_REPO="csukuangfj/sherpa-onnx-streaming-zipformer-bilingual-zh-en-2023-02-20"
|
||||
TTS_REPO="csukuangfj/sherpa-onnx-vits-zh-ll"
|
||||
|
||||
STT_DIR="$ROOT/models/stt"
|
||||
TTS_DIR="$ROOT/models/tts"
|
||||
WAV_DIR="$ROOT/wavs"
|
||||
mkdir -p "$STT_DIR" "$TTS_DIR" "$WAV_DIR"
|
||||
|
||||
fetch() { # fetch <repo> <remote-path> <local-path>
|
||||
local repo="$1" remote="$2" local="$3"
|
||||
mkdir -p "$(dirname "$local")"
|
||||
local url="$MIRROR/$repo/resolve/main/$remote"
|
||||
local expect
|
||||
expect=$(curl -sIL "$url" | grep -i '^content-length' | tail -1 | tr -d '\r' | awk '{print $2}')
|
||||
if [ -f "$local" ] && [ -n "$expect" ] && [ "$(stat -c%s "$local")" = "$expect" ]; then
|
||||
printf ' skip %-45s %s\n' "$(basename "$remote")" "(已完整)"
|
||||
return 0
|
||||
fi
|
||||
printf ' get %-45s %s\n' "$(basename "$remote")" "${expect:+$((expect/1048576))MB}"
|
||||
curl -sL --retry 3 --retry-delay 2 -o "$local.part" "$url" || { echo " FAILED"; return 1; }
|
||||
mv "$local.part" "$local"
|
||||
}
|
||||
|
||||
echo "=== STT: 流式 Zipformer 中英双语 int8 ==="
|
||||
for f in tokens.txt bpe.model \
|
||||
encoder-epoch-99-avg-1.int8.onnx \
|
||||
decoder-epoch-99-avg-1.int8.onnx \
|
||||
joiner-epoch-99-avg-1.int8.onnx; do
|
||||
fetch "$STT_REPO" "$f" "$STT_DIR/$f"
|
||||
done
|
||||
for w in 0.wav 1.wav 2.wav 3.wav; do
|
||||
fetch "$STT_REPO" "test_wavs/$w" "$WAV_DIR/stt_$w"
|
||||
done
|
||||
|
||||
echo "=== TTS: VITS zh-ll ==="
|
||||
for f in model.onnx tokens.txt lexicon.txt date.fst number.fst phone.fst new_heteronym.fst \
|
||||
dict/jieba.dict.utf8 dict/hmm_model.utf8 dict/idf.utf8 \
|
||||
dict/pos_dict/char_state_tab.utf8 dict/pos_dict/prob_emit.utf8 dict/pos_dict/prob_trans.utf8; do
|
||||
fetch "$TTS_REPO" "$f" "$TTS_DIR/$f"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== 结果 ==="
|
||||
du -sh "$STT_DIR" "$TTS_DIR" "$WAV_DIR" 2>/dev/null
|
||||
echo "下载完成。"
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""按目录递归下载 HF 仓库文件到本地(走 hf-mirror)。
|
||||
|
||||
Piper 中文 medium 档没有 lexicon.txt,改用 espeak-ng-data 做音素化,
|
||||
而那是一堆小文件,用 shell 逐个 curl 太啰嗦,所以写这个。
|
||||
|
||||
用法:
|
||||
python3 hf_fetch.py <repo> <本地目录> [远端前缀]
|
||||
python3 hf_fetch.py csukuangfj/vits-piper-zh_CN-huayan-medium \
|
||||
models/tts_piper_medium espeak-ng-data
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
MIRROR = os.environ.get("HF_MIRROR", "https://hf-mirror.com")
|
||||
UA = {"User-Agent": "curl/8.5.0"} # hf-mirror 对 urllib 默认 UA 返回 403
|
||||
|
||||
|
||||
def _open(url, timeout):
|
||||
return urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout)
|
||||
|
||||
|
||||
def api_tree(repo):
|
||||
url = f"{MIRROR}/api/models/{repo}/tree/main?recursive=true"
|
||||
with _open(url, 60) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def fetch(url, dest):
|
||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
tmp = dest + ".part"
|
||||
with _open(url, 300) as r, open(tmp, "wb") as f:
|
||||
while True:
|
||||
b = r.read(1 << 16)
|
||||
if not b:
|
||||
break
|
||||
f.write(b)
|
||||
os.replace(tmp, dest)
|
||||
|
||||
|
||||
def main():
|
||||
repo, outdir = sys.argv[1], sys.argv[2]
|
||||
prefix = sys.argv[3] if len(sys.argv) > 3 else ""
|
||||
files = [f for f in api_tree(repo) if f["type"] == "file"]
|
||||
if prefix:
|
||||
files = [f for f in files if f["path"].startswith(prefix)]
|
||||
print(f"{repo}: 取 {len(files)} 个文件 → {outdir}")
|
||||
for f in files:
|
||||
rel = f["path"]
|
||||
dest = os.path.join(outdir, rel)
|
||||
if os.path.exists(dest) and os.path.getsize(dest) == f.get("size", -1):
|
||||
continue
|
||||
# espeak 的 voices/!v/ 下有带空格的文件名(如 "Mr serious"),必须转义
|
||||
url = f"{MIRROR}/{repo}/resolve/main/{urllib.parse.quote(rel)}"
|
||||
try:
|
||||
fetch(url, dest)
|
||||
except Exception as e:
|
||||
print(f" 跳过 {rel}: {type(e).__name__}: {e}")
|
||||
continue
|
||||
print(f" {rel} ({f.get('size',0)/1024:.0f} KB)")
|
||||
print("完成")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"stt_threads": 2,
|
||||
"tts_threads": 2,
|
||||
"tts_model": "matcha",
|
||||
"solo_stt_rtf": 0.27664071878154955,
|
||||
"solo_tts_rtf": 0.44042636413212993,
|
||||
"conc_stt_rtf": 0.5378692789555439,
|
||||
"conc_tts_rtf": 0.935733770617253,
|
||||
"stt_degrade": 1.944288177548709,
|
||||
"tts_degrade": 2.1246088945223285,
|
||||
"conc_chunk_p95": 2.0250199799920665,
|
||||
"conc_chunk_max": 2.6675718699971185
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"stt_threads": 2,
|
||||
"tts_threads": 2,
|
||||
"tts_model": "piper",
|
||||
"solo_stt_rtf": 0.2645827264656701,
|
||||
"solo_tts_rtf": 0.1899799540144868,
|
||||
"conc_stt_rtf": 0.5013027566055182,
|
||||
"conc_tts_rtf": 0.3256982994144358,
|
||||
"stt_degrade": 1.8946919298246885,
|
||||
"tts_degrade": 1.714382452106499,
|
||||
"conc_chunk_p95": 2.345268959998066,
|
||||
"conc_chunk_max": 2.5172376999989865
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"stt_threads": 2,
|
||||
"tts_threads": 3,
|
||||
"tts_model": "vits",
|
||||
"solo_stt_rtf": 0.2826273522909462,
|
||||
"solo_tts_rtf": 1.43806433885311,
|
||||
"conc_stt_rtf": 0.6159577618900334,
|
||||
"conc_tts_rtf": 1.9989867974632771,
|
||||
"stt_degrade": 2.1793989750006415,
|
||||
"tts_degrade": 1.3900537990237043,
|
||||
"conc_chunk_p95": 2.319201170002998,
|
||||
"conc_chunk_max": 3.0467084500014607
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"chunk_ms": 100,
|
||||
"results": {
|
||||
"1": {
|
||||
"rows": {
|
||||
"stt_0.wav": {
|
||||
"audio_s": 10.053125,
|
||||
"total_s": 4.046593594999649,
|
||||
"rtf": 0.36610402895105676,
|
||||
"rtf_speech_only": 0.4025209668635026,
|
||||
"chunk_ratio_p50": 0.003555890002644446,
|
||||
"chunk_ratio_p95": 1.1945098499973028,
|
||||
"chunk_ratio_max": 1.2445684299973436,
|
||||
"first_partial_audio_s": 1.1,
|
||||
"tail_decode_ms": 352.4101550001433,
|
||||
"flush_ms": 0.06503600025098422,
|
||||
"final_text": "昨天是 MONDAY TODAY IS LIBR THE DAY AFTER TOMORROW是星期三",
|
||||
"n_partials": 91
|
||||
},
|
||||
"stt_1.wav": {
|
||||
"audio_s": 5.1,
|
||||
"total_s": 2.1389257700002418,
|
||||
"rtf": 0.35064356885249864,
|
||||
"rtf_speech_only": 0.419397209803969,
|
||||
"chunk_ratio_p50": 0.003521949997775664,
|
||||
"chunk_ratio_p95": 1.1893300500014448,
|
||||
"chunk_ratio_max": 1.1938205499973265,
|
||||
"first_partial_audio_s": 1.4,
|
||||
"tail_decode_ms": 356.69890200006193,
|
||||
"flush_ms": 0.02634899965414661,
|
||||
"final_text": "这是第一种第二种叫呃与 ALWAYS ALWAYS什么意思啊",
|
||||
"n_partials": 38
|
||||
},
|
||||
"stt_2.wav": {
|
||||
"audio_s": 4.69,
|
||||
"total_s": 2.0203764259999843,
|
||||
"rtf": 0.3550749430579937,
|
||||
"rtf_speech_only": 0.43078388614072155,
|
||||
"chunk_ratio_p50": 0.00355069999841362,
|
||||
"chunk_ratio_p95": 1.188613239996812,
|
||||
"chunk_ratio_max": 1.209888459998183,
|
||||
"first_partial_audio_s": 0.5,
|
||||
"tail_decode_ms": 356.0279449998234,
|
||||
"flush_ms": 0.02447099996061297,
|
||||
"final_text": "这是不是平凡的啊不认识你记下来 FREQUENTLY频繁的",
|
||||
"n_partials": 43
|
||||
},
|
||||
"stt_3.wav": {
|
||||
"audio_s": 8.83,
|
||||
"total_s": 3.5579121109999505,
|
||||
"rtf": 0.36194426358086984,
|
||||
"rtf_speech_only": 0.4029345539071292,
|
||||
"chunk_ratio_p50": 0.0035380899998926907,
|
||||
"chunk_ratio_p95": 1.1881532100005643,
|
||||
"chunk_ratio_max": 1.1958122499981982,
|
||||
"first_partial_audio_s": 1.1,
|
||||
"tail_decode_ms": 355.65589199995884,
|
||||
"flush_ms": 0.024543000108678825,
|
||||
"final_text": "第一句是个什么时态在加了 ES是一般现在时对吧后面那还有时时态来语写上上去了",
|
||||
"n_partials": 79
|
||||
}
|
||||
},
|
||||
"verdict": "⚠ 平均够但存在突发积压风险"
|
||||
},
|
||||
"2": {
|
||||
"rows": {
|
||||
"stt_0.wav": {
|
||||
"audio_s": 10.053125,
|
||||
"total_s": 2.578806391999933,
|
||||
"rtf": 0.23331016269154045,
|
||||
"rtf_speech_only": 0.25651788792041613,
|
||||
"chunk_ratio_p50": 0.003629650000220863,
|
||||
"chunk_ratio_p95": 0.7905124399985652,
|
||||
"chunk_ratio_max": 0.9809923500006335,
|
||||
"first_partial_audio_s": 1.1,
|
||||
"tail_decode_ms": 221.97086599999238,
|
||||
"flush_ms": 0.05037600021751132,
|
||||
"final_text": "昨天是 MONDAY TODAY IS LIBR THE DAY AFTER TOMORROW是星期三",
|
||||
"n_partials": 91
|
||||
},
|
||||
"stt_1.wav": {
|
||||
"audio_s": 5.1,
|
||||
"total_s": 1.3411688280002636,
|
||||
"rtf": 0.2198637422951252,
|
||||
"rtf_speech_only": 0.2629742800000517,
|
||||
"chunk_ratio_p50": 0.0035533499976736493,
|
||||
"chunk_ratio_p95": 0.7596391700008098,
|
||||
"chunk_ratio_max": 0.7848052599956645,
|
||||
"first_partial_audio_s": 1.4,
|
||||
"tail_decode_ms": 222.9477579999184,
|
||||
"flush_ms": 0.024468999981763773,
|
||||
"final_text": "这是第一种第二种叫呃与 ALWAYS ALWAYS什么意思啊",
|
||||
"n_partials": 38
|
||||
},
|
||||
"stt_2.wav": {
|
||||
"audio_s": 4.69,
|
||||
"total_s": 1.2377910049999628,
|
||||
"rtf": 0.2175379622144047,
|
||||
"rtf_speech_only": 0.2639213230277106,
|
||||
"chunk_ratio_p50": 0.003542499998729909,
|
||||
"chunk_ratio_p95": 0.7229457900029956,
|
||||
"chunk_ratio_max": 0.7791545099962605,
|
||||
"first_partial_audio_s": 0.5,
|
||||
"tail_decode_ms": 223.60373099991193,
|
||||
"flush_ms": 0.024873000256775413,
|
||||
"final_text": "这是不是平凡的啊不认识你记下来 FREQUENTLY频繁的",
|
||||
"n_partials": 43
|
||||
},
|
||||
"stt_3.wav": {
|
||||
"audio_s": 8.83,
|
||||
"total_s": 2.189363661999778,
|
||||
"rtf": 0.2227226512715949,
|
||||
"rtf_speech_only": 0.24794605458661131,
|
||||
"chunk_ratio_p50": 0.003571679999367916,
|
||||
"chunk_ratio_p95": 0.7330924600000799,
|
||||
"chunk_ratio_max": 0.8176678099971468,
|
||||
"first_partial_audio_s": 1.1,
|
||||
"tail_decode_ms": 213.40833099975498,
|
||||
"flush_ms": 0.025572000140527962,
|
||||
"final_text": "第一句是个什么时态在加了 ES是一般现在时对吧后面那还有时时态来语写上上去了",
|
||||
"n_partials": 79
|
||||
}
|
||||
},
|
||||
"verdict": "可实时"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
{
|
||||
"1": {
|
||||
"短句": {
|
||||
"first_ms": 3512.398196000049,
|
||||
"total_ms": 3512.4536819998866,
|
||||
"dur_s": 0.5618125,
|
||||
"rtf": 6.252003438869528,
|
||||
"text": "好的,我记住了。"
|
||||
},
|
||||
"中句": {
|
||||
"first_ms": 6728.828355000132,
|
||||
"total_ms": 6728.931756000293,
|
||||
"dur_s": 1.7795,
|
||||
"rtf": 3.781360919359535,
|
||||
"text": "今天北京晴转多云,气温十八到二十六度,出门记得带件外套。"
|
||||
},
|
||||
"长段": {
|
||||
"first_ms": 4259.075834999749,
|
||||
"total_ms": 4259.120488000008,
|
||||
"dur_s": 0.942875,
|
||||
"rtf": 4.517163450086181,
|
||||
"text": "我查了一下,明天上午十点有个会议,地点在三号会议室。另外提醒你,后天是妈妈的生日,要不要我提前帮你订一束花?还有,你上周说要买的那本书,现在有货了,价格是四十九块八。"
|
||||
},
|
||||
"数字": {
|
||||
"first_ms": 7015.1300140000785,
|
||||
"total_ms": 7015.235308999763,
|
||||
"dur_s": 1.735875,
|
||||
"rtf": 4.041325158205379,
|
||||
"text": "订单编号是七三九一,金额一千二百五十元六角。"
|
||||
},
|
||||
"日期": {
|
||||
"first_ms": 10958.226222000121,
|
||||
"total_ms": 10958.3969360001,
|
||||
"dur_s": 3.165375,
|
||||
"rtf": 3.4619585154997745,
|
||||
"text": "会议定在二零二六年九月十四日下午三点半。"
|
||||
},
|
||||
"中英混读": {
|
||||
"first_ms": 8337.178994000169,
|
||||
"total_ms": 8337.248050000198,
|
||||
"dur_s": 2.2680625,
|
||||
"rtf": 3.6759339965279607,
|
||||
"text": "这个 API 的 response time 大概在 200 毫秒左右。"
|
||||
},
|
||||
"_分句接续": {
|
||||
"max_gap_s": 3.4393423310002254,
|
||||
"total_audio_s": 2.6743125,
|
||||
"timeline": [
|
||||
{
|
||||
"i": 0,
|
||||
"text": "我查了一下,明天上午十点有个会议,地",
|
||||
"dur_s": 1.04,
|
||||
"gen_ms": 3355.619835000198,
|
||||
"first_ms": 3355.5726700001287,
|
||||
"gap_s": 0.0,
|
||||
"late": 0.0
|
||||
},
|
||||
{
|
||||
"i": 1,
|
||||
"text": "另外提醒你,后天是妈妈的生日,要不要",
|
||||
"dur_s": 1.1223125,
|
||||
"gen_ms": 4479.338345000087,
|
||||
"first_ms": 4479.295166000156,
|
||||
"gap_s": 3.4393423310002254,
|
||||
"late": 0.0
|
||||
},
|
||||
{
|
||||
"i": 2,
|
||||
"text": "还有,你上周说要买的那本书,现在有货",
|
||||
"dur_s": 0.512,
|
||||
"gen_ms": 1627.3659610001232,
|
||||
"first_ms": 1627.323898999748,
|
||||
"gap_s": 0.5050545779996796,
|
||||
"late": 0.0
|
||||
}
|
||||
],
|
||||
"total_gen_ms": 9462.324141000408
|
||||
}
|
||||
},
|
||||
"2": {
|
||||
"短句": {
|
||||
"first_ms": 1833.8097639998523,
|
||||
"total_ms": 1833.856194999953,
|
||||
"dur_s": 0.5618125,
|
||||
"rtf": 3.2641783424184276,
|
||||
"text": "好的,我记住了。"
|
||||
},
|
||||
"中句": {
|
||||
"first_ms": 3504.9386239998057,
|
||||
"total_ms": 3504.9927340000977,
|
||||
"dur_s": 1.7795,
|
||||
"rtf": 1.9696503141332382,
|
||||
"text": "今天北京晴转多云,气温十八到二十六度,出门记得带件外套。"
|
||||
},
|
||||
"长段": {
|
||||
"first_ms": 2213.8197690001107,
|
||||
"total_ms": 2213.8693260003492,
|
||||
"dur_s": 0.942875,
|
||||
"rtf": 2.347998754872437,
|
||||
"text": "我查了一下,明天上午十点有个会议,地点在三号会议室。另外提醒你,后天是妈妈的生日,要不要我提前帮你订一束花?还有,你上周说要买的那本书,现在有货了,价格是四十九块八。"
|
||||
},
|
||||
"数字": {
|
||||
"first_ms": 3649.676482999894,
|
||||
"total_ms": 3649.726979999741,
|
||||
"dur_s": 1.735875,
|
||||
"rtf": 2.1025286843809265,
|
||||
"text": "订单编号是七三九一,金额一千二百五十元六角。"
|
||||
},
|
||||
"日期": {
|
||||
"first_ms": 5695.0202110001555,
|
||||
"total_ms": 5695.0819460003,
|
||||
"dur_s": 3.165375,
|
||||
"rtf": 1.7991808066975636,
|
||||
"text": "会议定在二零二六年九月十四日下午三点半。"
|
||||
},
|
||||
"中英混读": {
|
||||
"first_ms": 4329.835705000278,
|
||||
"total_ms": 4329.891841000062,
|
||||
"dur_s": 2.2680625,
|
||||
"rtf": 1.9090707778114855,
|
||||
"text": "这个 API 的 response time 大概在 200 毫秒左右。"
|
||||
},
|
||||
"_分句接续": {
|
||||
"max_gap_s": 1.2875714189999234,
|
||||
"total_audio_s": 2.6743125,
|
||||
"timeline": [
|
||||
{
|
||||
"i": 0,
|
||||
"text": "我查了一下,明天上午十点有个会议,地",
|
||||
"dur_s": 1.04,
|
||||
"gen_ms": 1753.6386409997249,
|
||||
"first_ms": 1753.5868829995707,
|
||||
"gap_s": 0.0,
|
||||
"late": 0.0
|
||||
},
|
||||
{
|
||||
"i": 1,
|
||||
"text": "另外提醒你,后天是妈妈的生日,要不要",
|
||||
"dur_s": 1.1223125,
|
||||
"gen_ms": 2327.566012999796,
|
||||
"first_ms": 2327.5196609997693,
|
||||
"gap_s": 1.2875714189999234,
|
||||
"late": 0.0
|
||||
},
|
||||
{
|
||||
"i": 2,
|
||||
"text": "还有,你上周说要买的那本书,现在有货",
|
||||
"dur_s": 0.512,
|
||||
"gen_ms": 848.2465030001549,
|
||||
"first_ms": 848.2053440002346,
|
||||
"gap_s": 0.0,
|
||||
"late": 0.2740608039997383
|
||||
}
|
||||
],
|
||||
"total_gen_ms": 4929.451156999676
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"vits-zh-ll": {
|
||||
"desc": "VITS 中文,115MB",
|
||||
"rows": [
|
||||
[
|
||||
"短句",
|
||||
1023.8842699995985,
|
||||
0.44725,
|
||||
2.289288474006928,
|
||||
1
|
||||
],
|
||||
[
|
||||
"中句",
|
||||
2747.543926000162,
|
||||
1.648,
|
||||
1.6671989842233994,
|
||||
1
|
||||
],
|
||||
[
|
||||
"长句",
|
||||
1709.2012230000364,
|
||||
1.024,
|
||||
1.669141819335973,
|
||||
1
|
||||
],
|
||||
[
|
||||
"数字",
|
||||
3316.832393000368,
|
||||
1.81275,
|
||||
1.8297241169495893,
|
||||
1
|
||||
]
|
||||
],
|
||||
"rtf_avg": 1.8638383486289725,
|
||||
"rtf_max": 2.289288474006928,
|
||||
"gaps": [
|
||||
2.0050806700000976,
|
||||
1.562527183999915,
|
||||
0.4825053690001191,
|
||||
2.856295740000048,
|
||||
3.7974527159998246
|
||||
]
|
||||
},
|
||||
"piper-huayan-x_low": {
|
||||
"desc": "Piper 中文,20MB",
|
||||
"rows": [
|
||||
[
|
||||
"短句",
|
||||
188.33520399994086,
|
||||
1.104,
|
||||
0.1705934818840044,
|
||||
1
|
||||
],
|
||||
[
|
||||
"中句",
|
||||
584.8148079999191,
|
||||
3.408,
|
||||
0.17160058920185423,
|
||||
1
|
||||
],
|
||||
[
|
||||
"长句",
|
||||
670.672593999825,
|
||||
3.872,
|
||||
0.17321089721069863,
|
||||
1
|
||||
],
|
||||
[
|
||||
"数字",
|
||||
647.2685590001674,
|
||||
3.776,
|
||||
0.17141646159962062,
|
||||
1
|
||||
]
|
||||
],
|
||||
"rtf_avg": 0.17170535747404447,
|
||||
"rtf_max": 0.17321089721069863,
|
||||
"gaps": [
|
||||
0.4739460710002277,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user