技术实践报告 · 2026-10-28

同声传译模型 - Hy-SimulMT
Apple Silicon 上的实时同传实践

Hy-SimulMT-30B-A3B 在 Apple Silicon 上的部署、流式改造、
端到端系统构建,以及五轮故障排查的完整记录。

硬件 M2 Max · 64GB
系统 macOS (darwin)
量化 Q4_K_M · 18.39 GiB
自研代码 949 + 1036 + 260 行
3.49×
Metal 实时倍率
255ms
说话 → 字幕
1150ms
说话 → 出声
0ms
音频自丢(对账后)
SUMMARY

摘要

这个模型的官方推荐路径在本机跑不起来:vLLM 要 CUDA,llama-server 的 chat 模板缺一个关键标记。但它底层的推理能力完全可用——只要绕开官方那两层封装,直接对着它对音频的假设来喂数据。

三件事构成了全篇的主干:

一、用同一条推理内核,换掉输入形态。官方 llama-mtmd-cli 一次只能吃一个完整音频文件,每次都重载 18 GB 权重(约 8 秒),实时无从谈起。给它加一个 --stream:音频不从文件来,而是从 stdin 读裸 PCM,每凑满一轮就跑一次官方轮次逻辑。分块 schedule 一个字都不能改——首轮 1600 ms、之后每轮 800 ms,这是训练时定死的。

二、承认 TTS 是独立的延迟问题。官方 Qwen3-TTS 音质好、能克隆音色,但 MPS 上 RTF ≈ 1.3,永远追不上说话的人;macOS say 的 RTF 是 0.11,却背着每轮 0.87 秒的进程启动固定开销。实时场景里选后者,是把"能不能用"和"好不好听"分开决策。

三、真正的难点不在推理,而在音频链路。全篇最长的一章是故障排查,因为踩的坑几乎全部同构:系统在静默地丢东西,而界面不报错。独占锁没有租约、轮次编号跨会话重号、播放器死等一个永远不来的轮次、模型加载的 8 秒里音频被整段丢弃、尾部不满一块就永不结算——每一个都让程序"看起来在运行",实际上已经聋了。

一句话结论 30B MoE 的语音同传在 M2 Max 上完全可以本地实时运行,字幕延迟 255 ms、配音延迟 1150 ms,比专业同传的 2–3 秒更好。代价是:单向、约十分钟一换会话、以及一条必须自己建的、可观测的音频链路。
SECTION 01

模型介绍:一个为同传而生的语音模型

Hy-SimulMT 是腾讯混元于 2026 年 10 月 26 日发布的中英双向实时同传模型,Apache-2.0 开源——本文在它发布两天后开始在本机部署。这一节只交代四件事:它宣称能做到什么、这些数字用什么口径测出来、六个变体差在哪里、官方给了哪几条部署路径。后面每一个工程决策,最终都要落回这四件事。

1.1定位:语音、译文、音色一起交付

说话人的音频可以持续输入,模型边听边译,同步给出译文文字与译文语音,并支持实时音色克隆——在把一种语言换成另一种语言的同时,尽量保留原说话人的声音特点。面向的场景很明确:会议、演讲、访谈这类不能等你说完再翻译的实时交流。

官方仓库交付的不是单个权重文件,而是三件东西,理解这一点对部署很重要——因为"模型跑通了"和"服务跑通了"是两件事:

交付物内容与本文的关系
模型权重30B-A3B 主干 + 音频塔,共 6 个变体(精度 × 输出形态)本机使用 Interleave-GGUF Q4_K_M
SimulInfer推理服务:网页、音频文件上传、实时 WebSocket,可选 TTS 与音色克隆依赖 Linux + vLLM,本机不可用
SimulTrain基于已发布 checkpoint 的继续训练工具本文未涉及
一句容易漏读的前提 官方 README 明确写着:语音输出与音色克隆需要另行配置 TTS 服务。也就是说这个模型本身只负责"听懂并译出来",出声是另一个组件的事。这不是实现细节,而是本机部署里第二重的工程量——第六章整章都在处理它。

1.2官方评测:它被放在什么位置

同传的难点不在于把语言 A 变成语言 B,而在于话还没说完就得开始翻译。多等一点,语义判断更准,但听众等待更久;早开口,则对增量理解和译文组织提出更高要求。官方所有评测都围绕这条 质量与延迟的交换曲线展开。

长音频(RealSI)。StreamLAAL 约 3 秒,是全部参评系统里最低的值;GEMBA 翻译质量评分高于所对比的开源模型,并超过五个商业 API 中的四个。"低延迟不必以明显牺牲质量为代价",是这份评测想说明的结论。

短音频。FLEURS、CoVoST2、ACL6060、RealSI-Sentence 四个公开测试集上的得分如下(分数越高越好,加粗为该列最高分,浅底行为本模型):

系统FLEURSCoVoST2ACL6060RealSI-Sent.
Hy-SimulMT90.375.581.284.7
Gemini3.5-LiveTranslate92.474.482.682.2
Qwen3.8-LiveTranslate88.482.180.584.4
Confucius4 R2T2 → T3PO89.476.876.177.7
Seed-LiveInterpret2.085.867.774.880.0
SimulS2ST-Omni78.666.673.177.4
GPT-Realtime-Translate77.750.876.476.7
XunFei-Simult-Interpretation73.457.057.570.9
SeamlessStreaming72.860.949.856.9

读这张表要留意一件事:CoVoST2 是它唯一的明显短板——75.5 分,比 Qwen3.8 的 82.1 低了 6.6 分,也是四列里唯一没有进入前二的列。官方文案说"三个测试集高于表中其他开源模型",指的是 FLEURS、ACL6060 和 RealSI-Sentence,并不包括 CoVoST2。这类"结论覆盖到哪一列"的边界,比结论本身更值得记住。

与专业译员同场对照。官方另采集了 2026 年 9 月之后出现在互联网上的 18 段中英文音频(每段约 6 分钟,覆盖演讲、访谈、会议,横跨软件与 AI、医学、制造与能源、自然科学、农业与食品、商务管理、法律、教育、文化文旅、体育生活十个领域),并邀请六位有多年同传实战经验的译员分别处理——其中三位持 CATTI 一级口译证书、三位持二级,每个等级取三人中的最高分与模型对比。

官方的结论与它的适用范围 在这场小范围能力定位测评中,模型翻译质量达到人类同传专家水平,而速度往往更快(时延更低)。但该结论仅适用于本次样本、参测译员与评测设置,不代表对播报音质、音色相似度等维度的综合评价。
口径提醒:3 秒和 255 毫秒不是一回事 StreamLAAL 衡量的是"译文相对源语的滞后程度",它把"等待更多上下文"的时间也算在里面,官方也明确说明它不等同于首次开口延迟。本文第九章测到的 255 ms 是另一件事:一个 800 ms 音频块送进模型后,首个 token 多久出来。两者不可直接比较——不要拿 3 秒去减 255 毫秒。

1.3六个变体:为什么这里只能用 Interleave-GGUF

同一个 30B-A3B 主干被发布了六次,差别落在精度与输出形态两个维度上,刚好构成一张 3 × 2 的表。这两个维度里,第二个对本机的选择起决定作用。下表所有变体共享同一个前缀 Hy-SimulMT-30B-A3B,这里只写后缀:

精度仅译文原文 + 译文可用推理栈
BF16(基础版)-InterleavevLLM / SGLang-Omni
≈ 57 GiB
FP8-FP8-Interleave-FP8vLLM 需 CUDA
GGUF-GGUF-Interleave-GGUF
本文选用
llama.cpp
Q4_K_M · 18.39 GiB

也就是说,"能在 Mac 上跑"只在第三行,"能拿到原文"只在第三列,两者的交集只有一个变体:Hy-SimulMT-30B-A3B-Interleave-GGUF,本文用的是它的 Q4_K_M 量化(18.39 GiB)。前两行在本机没有讨论价值——BF16 与 FP8 都是 CUDA 栈的游戏。

"拿到原文"不是锦上添花,而是排查能力的来源。Interleave 版每次推理会同时返回源文分支与译文分支;当某一轮输出为空时,只有源文字段能回答"是模型没听出来,还是翻译环节丢了"。本文第十章那条反复出现的关键日志——空轮:源文也为空,这段音频模型什么都没听出来——就是靠这个字段把排查方向从"翻译质量"直接拨回到"音频链路"的。用 tgt_only 版,这个判断做不出来。

反过来,精度维度是被硬件逼的:BF16 的 57 GiB 只能落在 H200 这一档机器上,Q4_K_M 的 18.39 GiB 才塞得进一台 64 GB 统一内存的笔记本——而且还要给 KV cache、macOS 本身和 TTS 留出余量(第九章会看到,内存恰是这套方案真正的天花板)。

1.4三条官方推理路径,没有一条写着 macOS

官方给了三条部署路径,另有一个训练工具。它们的能力和前提差异很大,但有一个共同点:全部假设 Linux + NVIDIA。

路径形态与能力硬前提
SimulInfer + vLLM网页、文件上传、实时 WebSocket,含 VAD 与断句恢复策略;支持 TTS 与音色克隆Linux、Python 3.12+、vLLM ≥ 0.31(需支持 HYV3ForCausalLM)、NVIDIA GPU
llama.cpp
PR #31198
GGUF 推理;当前仅 llama-mtmd-cli 支持,llama-server 尚待适配该 PR 的分支;macOS 可编译
SGLang-OmniOpenAI 兼容 HTTP + 实时 WebSocket;只返回译文文本,不含语音与克隆Linux + CUDA;BF16 约 57 GiB,单张 H200
SimulTrain基于已发布 checkpoint 继续训练,需独立 Python 环境训练级 GPU

三个细节值得记住,它们决定了本文后续的形态:

  • 只有 llama.cpp 这条路沾得上 Apple Silicon。vLLM 和 SGLang-Omni 都是 CUDA 栈,本机连启动的机会都没有。
  • 而 llama.cpp 的支持只到"单文件推理"。PR 明确写着当前仅 llama-mtmd-cli 可用,llama-server 待后续适配。这意味着官方那条"起个服务、推 WebSocket"的用法,在 GGUF 上并不存在——必须自己造。
  • SGLang-Omni 那条路反而提供了反证。官方注明该集成"未复现 SimulInfer 的 VAD 与恢复策略"——说明 VAD 与断句恢复并不在模型内部,而是服务层的工程。这也解释了本文为什么必须自己写一套音频链路(第八章)。

所以本机的处境可以一句话概括:能跑的路径功能不全,功能全的路径跑不了。剩下的事,就是把"能跑"的那条补到"功能全"。这是第二章的起点。

SECTION 02

模型解剖:它到底是怎么"听"的

不理解"按轮次喂"这件事,后面所有工程决策都会做错。这个模型不是"给一段录音、还一段翻译"的批处理模型,它的推理过程被训练成了一个固定的节拍。

2.1架构与能力边界

维度参数工程含义
主干HYV3 MoE
30B 总参 / 3B 激活
每 token 只走 3B 的专家路径,所以 30B 的模型在消费级硬件上有实时可能
音频塔Whisper 风格 encoder与文本主干打包在同一个 GGUF 里,无需独立 --mmproj
任务zh↔en 语音到语音同传方向由指令语种决定,指令进上下文后不可改
上下文KV 96 KB/token16K 上下文 ≈ 1.5 GB;每 800 ms 涨一轮,约 9–11 分钟耗尽
许可Apache-2.0可商用,本机部署无授权障碍

2.2固定节拍:1600 + 800

模型被训练成一个边听边译的 checkpoint 序列:先听 1600 ms 建立上下文,之后每 800 ms 生成一次译文增量。每一轮结束都必须先把当前能确定的部分译出来,再去听下一段。这解释了它为什么能做到 255 ms 的字幕延迟——它压根不等你说完。

时间 → 音频流 1600ms 800 800 800 800 800 encode + decode → 输出第 0 轮 生成第 1 轮 生成第 2 轮 生成第 3 轮 每轮前置: <|hy_Assistant|><think></think><|hy_place▁holder▁no▁790|> ↑ 缺了这个分支标记,源文分支会退化,输出变成重复片段 首轮之后每轮还要补一个 <|hy_User|>,让每段音频成为独立 user message
图 1 轮次流水线。首块 1600 ms 走完整 encode + decode,之后每 800 ms 一轮只做增量生成。分块边界是训练时定死的,自己改成 3.2 秒一刀会让模型退化。

2.3特殊 token:三个必须精确的地方

这个模型的自定义 token 不是装饰,它们直接决定输出是否可用。

Token位置作用与后果
<|hy_begin▁of▁sentence|>序列开头BOS。缺了会走不寻常的起始路径。
<|reasoning_mode|>
reasoning_effort:no_think
BOS 之后关掉思维链。同传要的是即时输出,让模型"想一想"就等于加几百毫秒延迟。
<|hy_User|>指令前、每轮音频前user 角色。首轮在 prefix 里给,之后每轮重发。
<__media__>指令之后音频占位符,真正的 embedding 在这里被塞进序列。
<|hy_place▁holder▁no▁790|>助手回合开头Interleave 版必需。branch 标记,告诉模型接下来走 src→tgt 双分支。缺了源文分支会退化。
<|hy_place▁holder▁no▁791|>输出中间src / tgt 分隔符。解码时遇到它就从"源文"切到"译文",是两个字段的唯一分界。
两个变体的差别是反的 Interleave(src_tgt)版必须带 790 分支标记;tgt_only 版必须去掉它,否则返回空轮次。这两条正好相反,拿错一个就是"要么重复、要么空白"。

2.4为什么"按轮次"这个约束是好事

乍看这是个限制:你不能自己决定切块粒度。但它带来两个正好被工程利用的性质。

第一,天然背压。既然每轮必须吃满 800 ms 才能进推理,那输入侧就不可能无限超前。上游只需要保证"按实时速率送",模型自己会形成节拍,永远跑不到说话人前面。

第二,延迟可预测。单轮生成实测 115–235 ms,稳定在一个很窄的区间。这意味着端到端延迟基本等于"块大小 + 生成时间",是个可以算出来的常数,而不是需要反复试的玄学。

SECTION 03

部署路径的抉择

官方给了三条路,前两条在 macOS 上都断掉,但断的方式不同——一条是硬性不可用,一条是"能跑但结果是错的"。后者更危险。

Hy-SimulMT 在 M2 Max 上 vLLM / vLLM-Omni 官方主推路径 硬性不可用 需要 NVIDIA CUDA + 专用 kernel,Apple Silicon 无对应后端 llama-server SimulInfer Web UI 能跑,但结果是错的 chat 模板漏发分支标记 790 → 源文分支退化 → 输出变成重复片段 "下一季度我们将重点提升下一季度我们将重点提升" llama-mtmd-cli PR #31198 分支 唯一可行:直接控制 prompt 与轮次循环 Metal 3.49× 实时 / 纯 CPU 1.33×,可再加 --stream 改造
图 2 三条路径的实际结局。注意中间那条:它不是报错,而是返回看似合理的错误输出——这类失败比崩溃危险得多。

3.1为什么必须绕开 chat 模板

llama-server 走 OpenAI 消息协议,助手回合只发 <|hy_Assistant|><think></think>,漏掉了 790 分支标记。更根本的是,模板体系里没有"每轮重放助手头"这个概念——它是一个"一次对话若干条消息"的抽象,而这个模型要的是"一次对话、几十轮、每轮都要重新宣告我是助手、我要走 src→tgt 分支"。

硬套的结果不是报错,而是源文分支塌陷:模型只能走 tgt 分支,输出退化成把译文重复拼接。第一次看到 "下一季度我们将重点提升下一季度我们将重点提升" 这种输出时,很容易误以为是量化损失或者上下文污染,实际上是模板少了一个 token。

3.2顺带解决的一个疑问:单文件 GGUF 的投影器

这批 GGUF 是单文件打包:音频塔的权重直接并在文本模型里。官方 llama-mtmd-cli 会检测到并自动 fallback(日志里那句 --mmproj not given, using the projector packaged in ...),但 llama-server 只判断 params.mmproj.path 是否非空。

于是有个取巧但有效的做法:把 --mmproj 指向模型自己。

llama-server -m models/Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf \
             --mmproj models/Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf \
             -c 4096 -ngl 99 --port 8899

之后 /props 里 modalities.audio 变成 true,整段音频一次跑通(14.16 s 音频 1.76 s,约 8× 实时)。这条路验证了"模型在 Metal 上确实能跑",但因为 2.1 的模板问题,它不能用来做同传——最终只是作为一条性能上界的参照系。

SECTION 04

环境与模型获取

4.1下载通道:镜像不是备选,是唯一路

这是第一个"跟技术无关但卡住一切"的问题。HuggingFace 镜像在这台机器上完全不通——不是慢,是 HTTP 000(连接直接超时)。换成 ModelScope 直连后稳定在 10.3 MB/s。

# HF 镜像:实测 http=000,全链路超时,不要浪费时间重试
curl -I https://hf-mirror.com/...

# ModelScope 直连:10.3 MB/s,18.39 GiB 约 30 分钟
curl -L -o Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf \
  https://www.modelscope.cn/models/Tencent-Hunyuan/Hy-SimulMT-30B-A3B/resolve/master/\
Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf
文件大小说明
Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf19,746,809,728 B
18.39 GiB
主干 + 音频塔单文件。Q4_K_M 是实时性与质量的平衡点
Qwen3-TTS-12Hz-0.6B-Base2.3 GiB可选 TTS,音色克隆用

4.2编译:只要一个 target

不需要编整个 llama.cpp。只要 llama-mtmd-cli,48 秒零错误。

git clone -b hyv3-speech https://github.com/lihy715/llama.cpp.Hyv3Speech.git llama.cpp-hyv3
# commit e41ca42 —— 这个 PR 至今未合入 master
cd llama.cpp-hyv3

cmake -B build -DGGML_METAL=ON -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release --target llama-mtmd-cli -j 10
为什么必须用这个分支 master 上没有任何一个工具能加载这批 GGUF 的音频塔。PR #31198(作者 lihy715)不仅加了 mtmd_audio_stream_* 那套流式接口,还改了音频 tower 的实现细节。换分支不是选择,是前提。

4.3一个提前排除的风险

动手前担心过 GGML_OP_CONV_2D 在 Metal 上的实现约束:它要求 F32 输入输出 + F16/F32 权重,而音频塔第一层就是 conv2d。查过之后这条风险自然消解——投影器张量本来就全是 F32,条件天然满足。这类"看起来是坑其实不是"的验证,比事后 debug 便宜得多。

SECTION 05

离线跑通:先证明它能译

在动任何代码之前,先用官方工具跑一次完整音频。这一步的价值不是"跑通了",而是拿到一条干净的基线命令,后面所有改造都以它为准。

./llama.cpp-hyv3/build/bin/llama-mtmd-cli \
  -m models/Hy-SimulMT-30B-A3B-Interleave-Q4_K_M.gguf \
  --audio audio/test_en.wav \
  --temp 0 \
  -p '<|hy_begin▁of▁sentence|><|reasoning_mode|>reasoning_effort:no_think<|hy_User|>
      请完成下面的同声传译任务,将陆续收到的语音翻译成中文。请结合上下文边听边译,
      输出能够确定的译文,不必等待整句结束。<__media__><|hy_Assistant|><think></think>
      <|hy_place▁holder▁no▁790|>'

真实输出(14.16 s 英文音频):

Good morning, everyone.<|hy_place▁holder▁no▁791|>大家早上好。
Thank you for joining today's meeting. We are<|hy_place▁holder▁no▁791|>感谢大家参加今天的会议。
going to discuss the new<|hy_place▁holder▁no▁791|>我们将讨论
product roadmap for the next quarter. Our<|hy_place▁holder▁no▁791|>下一季度的新产品路线图。
...
run_turn_stream: 105 tokens, 9/17 turns emitted, 14.16 s audio in 4.06 s (3.49x realtime)

5.1两条基线的意义

Metal(ngl=auto)
3.49× · 4.06s
纯 CPU(-ngl 0)
1.33× · 10.68s
llama-server 整段
≈8× · 1.76s

几个值得注意的点:

  • GPU 比 CPU 快 2.6 倍,而且 CPU 那条 1.33× 与 PR 作者上报的 1.40× 吻合——这是一次便宜的外部交叉验证,说明测量方法没跑偏。
  • 轮数不同不代表质量不同:Metal 出 9 轮、CPU 出 14 轮,是浮点后端差异导致切轮粒度不同,译文内容一致。
  • 1.33× 是"能听"的下限。纯 CPU 能跑,但余量太薄——系统稍有负载就会掉到 1× 以下,而一旦低于 1× 就意味着延迟会无限累积。

5.2两个不影响结果但会误导人的告警

日志要不要管原因
special_eos_id is not in special_eog_ids忽略tokenizer 配置告警。整段与流式两种模式下输出都验证过,不影响。
audio input is in experimental stage忽略llama.cpp 对多模态音频的通用提示。
SECTION 06

语音合成:音质与实时的交换

同传的"传"如果只停在字幕,体验是不完整的。这里有两个 TTS 方案,结论是——实时的那个不好听,好听的那个不实时。

6.1官方方案:Qwen3-TTS-0.6B 在 MPS 上跑通

官方要求用 vLLM-Omni 部署(要 CUDA)。但 qwen-tts 这个 pip 包本身不依赖 CUDA,device_map="mps" 就能在 Apple Silicon 上跑。

uv venv tts-env --python 3.11
VIRTUAL_ENV=tts-env uv pip install qwen-tts modelscope
# torch 2.14.1 / transformers 4.57.3(严格 pin,新版会破)

./tts-env/bin/modelscope download --model Qwen/Qwen3-TTS-12Hz-0.6B-Base \
  --local_dir models/Qwen3-TTS-0.6B-Base

关键突破:绕开 sox

这个包默认用 audioread / sox 读参考音频,而系统没装 sox(brew install sox 被沙箱拦)。解法不是装依赖,是走另一条分支:_normalize_audio_inputs 接受 (np.ndarray, sr) 元组,走这条就完全跳过 _load_audio_to_np。

import soundfile as sf

wav, sr = sf.read("audio/test_en.wav")
# 传元组,不传路径 → 不进 audioread / sox 那条路
prompt = tts.create_voice_clone_prompt((wav.astype("float32"), sr))

精度选择的实测结论

精度结果判断
fp16probability tensor contains inf/nan不可用 logits 溢出
bf16RTF 1.39(fp32 是 1.38)不提速 白折腾
fp32RTF 1.30–1.38 稳定态采用 MPS 上的实际上限

逐轮实测(fp32 / MPS):

[prompt] 预编码耗时 5.77s
[turn 1]  1.92s ->  1.28s 音频  RTF=1.50
[turn 2]  3.29s ->  2.32s 音频  RTF=1.42
[turn 3]  5.11s ->  3.84s 音频  RTF=1.33
[turn 4]  5.62s ->  4.32s 音频  RTF=1.30
[turn 5]  2.23s ->  1.44s 音频  RTF=1.55
[total] 生成 18.17s / 音频 13.20s  RTF=1.38

音色克隆的客观验证

"听起来像"不算验证。用 librosa 的 pyin 提基频(F0):参考音频中位 166.8 Hz,合成中文 157.9 Hz,偏差 5.3%,P25–P75 区间几乎重叠。这才叫克隆成功。

一个反直觉的工程收益 create_voice_clone_prompt() 把参考音频预编码一次复用,避免每轮重新编码。这一步省下的是"每句都要听一遍参考音频"的固定成本。

6.2实时方案:macOS say 的固定开销之谜

Qwen3-TTS 的 RTF 1.3 意味着合成 1 秒语音要 1.3 秒——它永远追不上说话的人。换成 say,RTF 降到 0.11,但它有个隐藏成本。

OBS-01 一个字和一段七秒长文,耗时几乎一样 0.868s vs 0.916s

现象配音延迟稳定在 1.15 秒左右,无论这句话多长。测一个字的文本:0.868 s;测 7.5 秒的文本:0.916 s。

解释这 0.87 秒不是合成时间,是进程 fork + 语音资源加载的固定开销。合成本身只占几十毫秒(RTF 0.11)。

对策换 Eloquence 系语音只快 0.16 s,但音质差一档,不值得。真正的解法是进程内合成(AVSpeechSynthesizer),那是另一件事,不在本次范围。所以 1.15 秒就是当前方案的延迟下限,接受它。

6.3两个方案的取舍

Qwen3-TTS-0.6BmacOS say
RTF1.30 – 1.380.11(+ 0.87s 固定)
实时可用否 持续落后,越听越迟是
音色克隆支持 F0 偏差 5.3%不支持 固定音色
内存约 2.3 GiB 常驻无常驻
适用事后听、离线配音、录音转译实时同传

所以工程上是两套并存:./tts.sh 走离线全链路(音色克隆 + 拼接导出),./live.sh 走实时链路(say)。./live.sh --qwen 保留了切换能力,但文档里明确标注"只适合事后听"。

SECTION 07

实时化的三个突破

从"能译一段录音"到"能跟着你说话译",中间有三个必须依次解决的问题,顺序不能变。

7.1突破一:让工具接受"音频从流里来"

官方 llama-mtmd-cli 的输入是 --audio <file>。每次调用都要重新加载 18 GB 权重(约 8 秒)——实时无从谈起。而它内部的 run_turn_stream 函数其实已经实现了完整的轮次循环,只是音频从文件里读。

所以改造的思路不是重写,是换掉数据源:新增 --stream,音频从 stdin 读裸 PCM16LE 单声道 16 kHz,其余逻辑一行不动。

+ static bool g_stream_stdin = false;
+ static int  g_stream_first = 1600;   // 首轮音频毫秒数
+ static int  g_stream_step  = 800;    // 之后每轮毫秒数
+
+ static int run_turn_stream_stdin(mtmd_cli_context & ctx, ...) {
+     // 与 run_turn_stream 完全相同的轮次逻辑,只把 fname 换成 stdin
+ }

规模:tools/mtmd/mtmd-cli.cpp | 260 ++++++++++++++,净增 260 行,没有改动任何既有函数。

核心循环

while (!g_is_interrupted) {
    // 本轮要吃的样本数:首轮 1600ms,之后 800ms
    const size_t want = (size_t) rate * (first ? first_ms : step_ms) / 1000;

    // 阻塞读,凑不满不进推理 —— 这就是节拍器
    while (pcm.size() < want && !eof) {
        int16_t buf[4096];
        size_t nmax = std::min<size_t>(want - pcm.size(), 2048);
        size_t got  = fread(buf, sizeof(int16_t), nmax, stdin);
        if (got == 0) { eof = true; break; }
        for (size_t i = 0; i < got; i++)
            pcm.push_back((float) buf[i] / 32768.0f);
    }

    // 每段音频是独立 user message,第二论起要补角色 token
    if (turn > 0) decode_tokens({ t_user });

    // 音频 → embedding → 塞进 KV
    mtmd_encode_chunk(ctx.ctx_vision.get(), c);
    // ... add_embd 后 llama_process(DECODE)

    // 重放助手头 + 分支标记,然后逐 token 生成
    decode_tokens(post_toks);
    // 遇到 791 切到 tgt 字段,遇到 EOG 结束本轮
}
设计上最省事也最正确的一点 stdin 的阻塞读本身就是节拍器。凑不满一轮就不进推理,所以模型永远不会跑到说话人前面,也不需要额外的流控。反过来,它天然构成背压:上游想灌更多数据,会先卡在 fread 上。

输出协议:JSON Lines

{"type":"ready"}
{"type":"turn","i":0,"ms":1600,"src":"Good morning, everyone.","tgt":"大家早上好。","n_gen":11,"gen_ms":149.0,"pos":312}
{"type":"turn","i":1,"ms":800,"src":"Thank you for joining today's meeting.","tgt":"感谢大家参加今天的会议。","n_gen":14,"gen_ms":187.3,"pos":389}
{"type":"done","turns":17}

字段里 pos 特别有用——它是当前上下文位置,进程靠它判断什么时候该换一个干净会话。gen_ms 则直接给出单轮生成耗时,是延迟分解的原始数据。

7.2突破二:一个必须拒绝的诱惑

改造过程中最大的诱惑是"自己决定块大小"。既然要边听边译,那切 3.2 秒不是更省算力、上下文也更连贯吗?

不行。这个 schedule 是训练时定死的。自己切 3.2 秒的实测结果是译文重复、轮次错位——模型在训练中见到的永远是"1600 之后每 800 一小步",换粒度就等于让它做一件没学过的事。

这条约束一直延伸到服务端:Python 侧的切块粒度也必须是 800 ms,因为 C++ 首轮吃 1600(= 2 块),只有 1600 是 800 的整数倍,块边界才天然对齐。对齐之后才敢做整块丢弃——丢半块会让后面所有音频错位一格。

7.3突破三:让它常驻

前两个突破解决"能不能流式",第三个解决"能不能一直流式"。做法是把 llama-mtmd-cli 作为子进程常驻,stdin 接音频、stdout 读 JSON、stderr 归日志,外面套一层 Python 服务管生命周期。

这带来一组必须处理的新问题,全部落在服务端:进程崩溃怎么恢复、上下文满了怎么换会话、两个浏览器同时开怎么仲裁、TTS 跟不上怎么不堵住字幕。这些是第 8 章的内容。

SECTION 08

系统架构

整条链路有四个进程/线程组,边界很清楚:浏览器负责采集与播放,Python 负责编排与账本,C++ 负责推理,say 负责发声。

浏览器 PYTHON 服务 C++ 推理 TTS 麦克风采集 AudioWorklet 重采样 → Int16 48k → 16k 逐句字幕 DOM 追加 按序排队播放 Web Audio PCM16 音频 ↓ JSON:turn / audio / status ↑ WebSocket 握手 自己实现 · 零依赖 stage 缓冲 + 切块 800ms 对齐 TTS 队列 2 worker + emitter watchdog / 账本 1.5s 采样 pcm_owner 独占锁(4s 租约) turn_seq 全局唯一 · 加载期音频攒进 stage 后补送 ↑ 两者都只表现为「突然没反应」 stdin llama-mtmd-cli --stream 常驻进程 · 首轮 1600 + 每轮 800 18.39 GiB Q4_K_M · Metal stdout:JSON Lines KV 缓存 96 KB/token 16K ≈ 1.5 GB head_count_kv = 4 会话轮换 pos 接近上限 9–11 分钟 / 次 界面插一条分隔线 ↑ 队列按轮次顺序派发 emitter 最多等 3 秒,超时跳过 say -v Tingting LEI16@24000 live-cache/*.wav 超 400 条淘汰最旧 音频走下行、译文走上行、配音再下推 —— 三条流互不阻塞;C++ 进程只做一件事:按节拍吃音频、吐 JSON
图 3 四层架构。Python 服务是唯一有状态的一层:它持锁、记账、仲裁、看门。C++ 进程只做一件事——按节拍吃音频、吐 JSON。

8.1线程模型

线程职责关键约束
writer每个客户端一条,串行推送出去的帧队列满则丢弃,绝不阻塞生产者
_reader读模型 stdout,解析 JSON 行此处绝不能写 stdin,会与模型互锁
_drain把 stage 里的音频切块写进 stdin独立线程。写满管道会阻塞,不能跑在 reader 里
tts_worker ×2调用 say 合成单轮并发数 2 是上限,再多会各自加载语音资源互拖
tts_emitter按轮次 id 顺序广播音频等一轮最多 3 秒,超时跳过而不是死等
watchdog1.5 秒采样,判链路是否还活着6 秒无增长才告警
一个真实存在的死锁 _drain 向 stdin 写入,管道写满就会阻塞;如果它跑在读 stdout 的线程里,模型输出没人消费,两边就互相卡死。
正确做法是加载完成后由独立线程补送,reader 线程只负责读。日志里那句「补送加载期间积压的 2206ms 音频」就是这条路径。

8.2关键参数与它们的理由

参数值为什么是这个值
CTX1638416K ≈ 1.5 GB KV。本机 swap 长期 15 GB+,开 32K 会让模型页被换出,推理直接失去实时性。省内存比拉长会话值。
BLOCK_MS800与 C++ 的 1600(=2×800)严格对齐,才敢整块丢弃。
MAX_BACKLOG_MS10000允许落后 10 秒。定得比"还能听"宽得多——宁可暴露延迟,不能偷吞内容。
PCM_LEASE4.0 s独占锁必须有租约。客户端断开时 drop_client 不一定被调用,没租约就永久锁死。
N_PREDICT160单轮生成上限。800 ms 音频实测远用不完。
SILENT_RMS1e-3低于此值算静音。连续 4 秒触发界面告警。
TTS_WORKERS2从 3 降下来。并发 say 各加载一份语音资源,内存紧张时互相拖死。

8.3独占锁:一个必须存在的粗暴机制

两路 PCM 写进同一个 stdin 会互相插帧——字节数对得上,但样本交错,模型听到的是两段音频按位混合的噪声,结果就是"什么都听不出来"。这个 bug 是在"Chrome 假麦克风还在推流、同时用 /api/simulate 灌测试音频"的场景下暴露的。

解法是第一个开口的客户端独占,但它必须带租约:

now = time.time()
if self.pcm_owner is not None and self.pcm_owner is not owner:
    if now - self.pcm_lease < self.PCM_LEASE:      # 4 秒内
        self.rejected_bytes += len(data)           # 记账,不静默
        return
    log("音源独占已过期,交由新音源接管")
self.pcm_owner = owner
self.pcm_lease = now

注意 rejected_bytes 这个字段——被拒绝也要记账。否则调试时会看到"收到的字节数怎么比送进模型的少",误判成丢包。

SECTION 09

性能实测

延迟不是一个数字,是一条分解链。把每一段拆开,才知道优化该往哪打。

说话 → 字幕 255 ms 采集 100ms 传输 ~40ms 生成 115–235ms 回推 ~20ms 255 ms 说话 → 出声 1150 ms 采集 100 传输 40 生成 115–235 say 进程固定开销 ≈ 870 ms 一个字与七秒长文耗时几乎相同 参照系 本机字幕 255ms 本机配音 1150ms 专业同传滞后 2000–3000ms
图 4 延迟分解。字幕路径(255 ms)几乎全部来自生成;配音路径(1150 ms)里 870 ms 是 say 的进程启动固定成本,与文本长度无关。这就是当前方案的下限。

9.1全量数据

指标数值备注
模型加载(冷启)约 8 s页缓存热后 2 s
单轮生成(800 ms 音频)115 – 235 ms非常稳定,可直接当常数用
说话 → 字幕平均 255 ms比专业同传好一个数量级
说话 → 出声平均 1150 ms其中 870 ms 是 say 固定开销
连续压测(66 s 音频)82 轮积压恒定 0–800 ms,丢弃 0
浏览器全链路(96 s)87 行字幕 / 86 段配音无中断、无告警
KV 缓存96 KB/token16K 上下文约 1.5 GB
整段批处理(14.16 s)3.49× 实时Metal;纯 CPU 1.33×

9.2内存是这个方案的真正天花板

所有性能问题里,最反复出现的一个不是算法,是swap。

OBS-02 连续压测把 swap 打爆,RTF 从 1.38 退化到 6.97 17.4 GB / 18.4 GB

事件连跑 5–6 个批处理任务(每个都加载一次 18 GB GGUF)后,swap 用到 17.4 GB / 18.4 GB,TTS 的 RTF 从 1.38 掉到 6.97,翻译加载从 1.9 s 涨到 15 s。

性质不是代码 bug。模型权重页被换出到磁盘,每次推理都要从 SSD 读回来。

处置等系统回收后自然恢复。但这件事促使把 CTX 从 32768 降到 16384,省下 1.6 GB KV——在内存紧张的机器上,"少花 1.6 GB"比"多撑十分钟"值钱得多。

延伸这个现象还解释了另一类"说不清"的故障:say 在 swap 已满时启动会被无限拉长,3 个 worker 同时卡住,emitter 按序死等 → 所有音频广播中断,但字幕还在出。看起来是"半死不活",其实是内存。

SECTION 10

故障排查实录

这一章是全篇最长的,因为真正的成本都在这里。五轮排查,每一种故障都长得像"程序没反应",但根因分布在完全不同的层——从 C++ 的分块边界,到浏览器的 AudioContext 拓扑,再到 macOS 的 TCC 权限。

10.1第一类:静默失败的模式

把五轮里所有 bug 摆在一起看,会发现它们共享同一个形状:

共同特征后果为什么难查
进程活着、计时在走、界面无报错用户以为"卡了"没有任何异常可抓
字节数一直在涨账本看起来完全对平全零样本也能让账本完美对平
日志被节流i % 15 == 0 让 50 轮空轮只打 4 行抽样掩盖了连续性
错误提示被样式隐藏运行时给状态条加 hide"连接断开"四个字压根没显示过

所以第一轮修复的重点不是某个具体 bug,而是让失败显形:心跳、租约、账本、落盘、告警。后面几轮的定位速度能快起来,全靠这一轮铺的可观测性。

10.2五轮排查

R1 说了几句,接着就完全没反应 10:12 · 四个叠加

现场模型进程存活,pos 冻结在 757 不再变化,TTS 有最后一条成功日志,没有"进程结束"。

根因四个独立缺陷叠加,任一都足以致命:

  • TTS emitter 死等 30 秒。swap 满时 say 启动被无限拉长,3 个 worker 同时卡死;emitter 按序等第 67 轮,waited > 30 才跳过,期间所有 audio 广播全断。复现日志:TTS 第 67 轮超时,跳过。
  • 轮次编号跨会话重号。turn_seq 在 start() 里归零,但 tts_order/tts_done 没清 → 新会话的 0/1/2 与上一会话残留同号,播放器等一个永远不来的轮次。
  • 独占锁无租约。连接断了但 drop_client 没被调用,锁永久指向死客户端 → 之后所有音频被静默 return。
  • 尾部不结算。不足 800 ms 的尾巴永远躺在 fread 缓冲里等下一块。

修复emitter 等待 30 s→3 s;say 超时 60 s→8 s;worker 3→2;turn_seq 改为全局唯一;独占锁加 4 s 租约;静音 1.2 s 后补零结算;前端加心跳 + 退避重连。

验证66 s 音频 → 82 轮,积压恒定 0–800 ms、丢弃 0、TTS 超时 0;尾部那句「把推理延迟降下来」正常结算(修复前停在 [78])。

R2 中间有遗漏,尾部也没了 10:50 · 五个丢点

质疑是输入语音处理有问题,还是模型本身?(这个问题问得对,我之前一直没分清。)

先排除模型原生 16 kHz 与原生 48 kHz(say --data-format=LEI16@48000 合成,自带齿音高频)分别灌入,模型都完整译出,一句不落。结论:模型和字节链路都没问题,是链路里有五个点会把音频"删掉"。

  • 前端 sendPCM 先清空缓冲再判断能否发送 → WS 短暂不 ready 就永久丢 100 ms。
  • 模型加载那 8 秒 push_pcm 直接 return → 切方向/重置期间说的话整段消失,且不计入任何统计。
  • 尾部 < block/3(266 ms)不结算 → 说一停顿,最后半句原地等。
  • 积压 > 2400 ms 就整块丢弃 → 为追平实时而偷吞内容。
  • 多窗口抢占静默丢弃 → 后开的页面完全没反应且无提示。

修复发不出去就攒着(上限 12 s);加载期音频攒进 stage、ready 后由独立线程补送;尾部门限降到 50 ms;丢弃阈值提到 10 s 且必打日志;新增 busy 消息提示"另一个窗口正在占用麦克风"。

重大收获同口径音频账本(见 11.1)。11 秒 48k 音频实测 recv 39186 / model 38400 / done 38400 / stage 786 / discard 0,差额 786 ms 与 stage_ms 精确吻合。

R3 上来就不起作用,还弹「麦克风没有声音」 15:15 · 电平问题

先解释提示那句话不是程序坏了,是程序在汇报事实:页面每秒上报一次能量,连续 4 秒低于 -60 dBFS 就判定"没有声音进来"。

客观数据落盘的 1005 秒原始采样里,每秒 RMS 最高只有 50,正常说话是 300–3000;969 秒的 RMS < 10。当前会话 180 秒 / 224 轮全部空轮。

对照同一会话里灌入已知音频,模型立刻完整译出 → 模型完好,问题在"送进去的是什么"。

改动输入设备下拉(本机有 tranScreen Audio 虚拟声卡和 iPhone 连续互通麦克风,默认很容易落错);实时 dBFS 数字;软件增益兜底(包络归一到 0.22,上限 150×,门限 8e-5 防放大底噪);"测一下麦克风"按钮——录 4 秒、报电平、原样回放。

教训「没有声音」这句话无法行动,必须给数字。「原样回放」是唯一能让用户自己判定"到底收没收到"的手段。

R4 刚开始正常,静默一分钟后再说就没反应 16:30 · AudioContext 拓扑

关键新信息"开头正常"这一条推翻了上一轮"全程静音"的判断——信号是好的,是运行中失效。

落盘分析最后 90 秒:非零采样占 76.4%(不是断流,设备在采),但 RMS 只有 2–5,峰值 1386。也就是麦克风还开着,但采到的是近乎寂静的底噪。

时间线咬合

14:50:32  [76] '你好,今天你在干啥?'  → 识别正常   ✅
14:50:33  触发配音 TTS(1.24s 音频)
14:51:07  之后 197 秒全部空轮,level 卡在 0.0001

根因采集和播放共用一个 AudioContext,而播放链路必须接 destination(硬件输出)。macOS 上内置麦克风与扬声器挂在同一个 AudioUnit——一旦有声音开始播放,AudioUnit 从"纯输入"切到"双向",采集流在切换中被拆掉,Chrome 补不回来。配音一响,麦克风就哑了。

修复采集独立成自己的上下文,终点改为内存里的 MediaStreamDestination,彻底不碰硬件输出;去掉原来那条"0 增益 Gain → destination"(就是它让采集上下文也占了一路硬件输出);轨道 mute/ended 立刻重建;连续 25 秒数字静音且距上次重建超 30 秒则自动重连一次。

为什么之前测不出来假麦克风不绑物理输出设备,永远不触发这个切换。用 --use-file-for-fake-audio-capture 跑通的 96 秒 / 87 行字幕,恰好绕过了故障路径。这是本轮最重要的方法论教训。

R5 自动化测不到真实麦克风 末轮 · TCC 权限陷阱

尝试用 puppeteer 拉起 Chrome,四种接法(硬件输出 / 内存 sink × APM 开 / 关)、三个通道逐项拆分,全部拿到精确的 0.000000。同时在扬声器放 1 kHz 测试音也收不到。

解释macOS 把麦克风权限(TCC)判给启动 Chrome 的那个进程。子进程拿到的是静音流,而不是报错——所以既不能当"设备坏了"的证据,也不能当"代码对了"的验证。

结论真实麦克风这条路径只能由用户亲自点。自动化能验证的只有两件事:代码在 fake device 下是否正确,以及服务端逻辑是否自洽。

附带发现/live.html 是 404,正确路径是根路径 /。前几次探针全打在一个 404 JSON 页上——虽然不影响 getUserMedia,但干扰了判断。

10.3其余工程坑(速查)

坑现象解
numba 缓存清理被安全钩子杀进程服务启动即消失,无 traceback设 NUMBA_CACHE_DIR=/tmp/...,必须在 import 前
预热是陷阱持推理锁跑不受控推理,卡住后拖死全部任务(实测 300 s)去掉预热,让首个任务自然承担 kernel 编译开销
macOS 没有 timeout 命令用错得到假的 exit 0,误判脚本正常用 Python 侧超时或 gtimeout
mtmd-cli 译文走 stdout、日志走 stderr解析器等 stdout 里的日志,0 轮命中只解析 stdout 的 JSON 行
0 % 12 == 0 恒真秒推「已 0 秒没收到声音」守卫必须加 stall_sec >= 6
html,body{height:100%}破坏 sticky 顶栏(flex 容器限高)改 html{height:100%} + body{min-height:100%}
回放历史字幕自动滚到底首屏被顶栏遮住autoScroll 加 if (!S.running) return
运行中控件放在滚动区一滚就找不到"结束"按钮红点/计时/音量条全部移到顶栏
/api/simulate 用 sleep(0.1) 累加节奏漂移,延迟测量从 255 ms 虚高到 500 ms按绝对时钟对齐
Chrome headless 起 remote debugging必须加特定 flag--no-sandbox --user-data-dir=<独立目录>
SECTION 11

方法论:从"猜"到"量"

这一章不是技术细节,是这次真正学到的东西。前两轮我判断错了两次,原因都不是不懂技术,而是把"看起来一致"当成了"证明了"。

11.1音频账本:一条能定位丢在哪的尺子

核心思路:把音频在链路上的每一步都换算成毫秒,用同一个口径记账。这样任何一处丢失都会变成两个数字之间的差,而不是靠听感猜。

gen_ms 浏览器采集 sent_ms 实际发出 recv_ms 服务端收到 model_ms 写进 stdin done_ms 模型处理完 判据 recv_ms ≈ model_ms ≈ done_ms 且 discard_ms = 0 并且 recv_ms − model_ms 必须恰好等于 stage_ms(未满一块的尾巴) 实测(11 秒原生 48 kHz 语音) recv 39186 ms / model 38400 / done 38400 / stage 786 / discard 0 ← 精确对平
图 5 音频账本。五段同口径时长 + 两个异常计数(discard_ms 自己丢的、rejected_ms 被别人占锁拒收的),任何一处丢失都会在差值里显形。
这条尺子差点骗了我 第一轮我把"账本对平"当成了链路健康的证据。但全零样本同样能让账本完美对平——字节数一秒不差,只是内容是静音。
账本能证明"没丢",不能证明"有内容"。要证明后者,必须看电平(RMS / dBFS)或落盘回放。

11.2落盘取证:把"猜"变成"看"

一旦把收到的原始 PCM 落到 live-cache/recv.pcm,很多争论就消失了。可以直接算每秒 RMS、画包络、截取片段重放:

落盘总时长 1005.0s  文件 30.2 MB
全文件最高峰值 1386   最高每秒 RMS 50
RMS 分布:  <10: 969 秒   10–50: 31 秒   50–200: 5 秒   >200: 0 秒
                                        ↑ 正常说话应该在 300–3000

这组数字一次性回答了三个问题:设备在采吗(在,非零占 76%)、有声音吗(几乎没有)、是代码问题吗(不是,采到的东西本身就没能量)。

11.3三条被实践否定的"验证方式"

看起来合理的验证为什么无效正确做法
用假麦克风跑通全链路 假设备不绑物理输出,永远不触发 AudioUnit 的模式切换。96 秒 / 87 行字幕全部通过,真实设备一秒就哑。 假设备只用于验证代码逻辑;真实设备的问题必须真实设备验。
把 16k 上采样到 48k 做重采样对照 上采样不会造出 8 kHz 以上的能量,降回去必然无损——实验设计本身没有信息量。 要造真实条件,必须用原生 48k 合成/录音(自带齿音高频)。
用 puppeteer 测真实麦克风 macOS 把 TCC 判给父进程,子进程拿到静音流而不是报错。 让用户自己点「测一下麦克风」;自动化只测 fake device 路径。

11.4排查期间的自伤要如实说明

排查过程中我重启了三次服务,每次模型重载 8 秒。如果用户正好在说话,那几句必然丢——这也在他感知的"尾部没了"里。这类由排查动作本身引入的噪声,必须主动告知,否则会把用户推向错误结论。

一条可以推广的规则 当一个系统有很多"静默失败"点时,先建可观测性,再改代码。R1 铺的心跳、租约、账本、落盘,让 R2–R5 的定位速度提升了数倍。反过来,先改代码再补观测,只会积累更多"不知道有没有修好"的改动。
SECTION 12

边界与去向

12.1当前方案的硬边界

限制原因能否绕过
只能单向方向由指令语种决定,指令在首轮就进了上下文换方向要重启模型(约 8 s)。可以预启两个进程并存,代价是内存翻倍
约 9–11 分钟换会话16K 上下文,每 800 ms 涨一轮开更大上下文,但内存紧张的机器上会失去实时性
同一时刻只接受一个音源两路 PCM 写同一 stdin 会互相插帧不解。这是物理约束,只能仲裁
AI 配音不实时Qwen3-TTS RTF 1.3;say 有 870 ms 固定开销进程内合成(AVSpeechSynthesizer)可以消掉 870 ms,但要写 ObjC/Swift 桥
外放会自激模型听到自己的译文,当成新输入再译一遍戴耳机,或启用 AEC(但 AEC 又会压近端语音,是个权衡)

12.2如果继续做,优先级排序

  1. 进程内 TTS。870 ms 是当前配音延迟的 76%,用 AVSpeechSynthesizer 可以直接砍掉。这比换模型、调参数都划算。
  2. 双向常驻。预启 zh→en 与 en→zh 两个进程,按方向切换路由。内存不够就在切换时懒加载。
  3. 音频链路的形式化。把账本从"事后 curl"变成界面上的常显数字,让用户自己就能判断丢在哪一环。
  4. 向上游反馈 mmproj 与模板的修复。PR #31198 若合入 master,并且 llama-server 的模板支持"每轮重放助手头",这套自研服务就可以简化掉一大半。这是最该做的事——把临时方案变成上游能力。

12.3这个实践最后证明了什么

30B 的 MoE 语音模型在消费级笔记本上做实时同传,不是"理论上可行",是现在就能用——255 ms 的字幕延迟已经优于人类同传。

真正的门槛不在模型。模型很配合:它只要按训练时的节拍被喂数据,就会稳定地吐出高质量译文。门槛在那条把自己和它连起来的链路:浏览器的音频拓扑、操作系统的权限与时钟域、进程间的管道与锁、以及内存这张永远不够大的桌子。

这次最大的教训是:当系统"看起来在运行"时,最该怀疑的不是逻辑,而是有没有东西在静默地消失。而对抗静默的唯一手段,是让每一步都能被量出来。

SECTION 13

复现清单

13.1文件

文件行数作用
live.py949同传服务:进程管理、WebSocket、TTS、HTTP、账本、看门狗。零第三方依赖
web/live.html1036界面。单文件、零外部依赖,采集/播放/告警全在里面
mtmd-cli.cpp1192官方文件 + --stream 改造(+260 行)
live.sh / run.sh / tts.sh / ui.sh—实时 / 离线字幕 / 离线配音 / 批量 UI 四个入口
pipeline.py · server.py—离线全链路、批量 Web UI(端口 8765)
LIVE.md189运行手册与判据速查

13.2三步启动

# 1. 实时同传(推荐:先戴耳机)
./live.sh                    # → http://127.0.0.1:8770

# 2. 没麦克风也能验证链路:把 wav 当麦克风灌进去
curl -X POST --data-binary @audio/test_zh.wav \
  "http://127.0.0.1:8770/api/simulate?realtime=1"

# 3. 怀疑漏句时,看账本
curl -s http://127.0.0.1:8770/api/status | python3 -m json.tool

13.3故障速查

界面提示含义是否自愈
已 N 秒没收到声音服务端确认字节数不再增长,链路真断了前端随后判死重连
连接中断,正在重连…心跳 9 秒无回执(TCP 半开)是,退避重试最多 30 次
麦克风听不到声音(-83 dB)设备在采,但能量接近 0否,要查设备/权限/电平
另一个窗口正在占用麦克风多页面同时打开,只认第一个开口的是,4 秒租约后自动接管
计时在走、无提示、无新字幕模型在推理,或音频不足一块正常,等尾部结算(1.2 s)
一个必须先确认的前提 改动前端之后务必强刷(Cmd + Shift + R)。服务端已发 no-store,但普通刷新仍可能命中旧脚本——这一点在排查中实际误导过判断。