一个 30B-A3B 的语音同传模型,官方工具只能吃「一个完整音频文件」,每次调用重新加载 18GB 权重。 这个项目改了 llama.cpp 的 260 行 C++,把它变成一个常驻的流式进程,再配一层零依赖的 Python 服务和一个 双 AudioContext 隔离的浏览器前端,把端到端字幕延迟压到 255ms —— 全部在本机,不联网。
模型进程常驻 + stdin 当节拍器 + 800ms 固定分轮。
Python 服务把浏览器送来的 PCM 按 800ms 切成块,写进一个长跑的
llama-mtmd-cli --stream 进程的 stdin;这个进程凑满一块就跑一轮推理,
把「源文 + 译文」以 JSON 行打回 stdout。stdin 的阻塞读天然保证了模型不会跑到说话人前面,
而分轮 schedule(1600ms 首轮 + 800ms 每轮)完全复用官方实现 —— 因为它是训练时定死的,自己切块模型就会退化。
剩下的工程量几乎全在可观测性与自愈上:音频账本、六类故障的独立信号、 麦克风断流自动重建、空轮自动重启会话。因为「界面没反应」这一个症状,背后有六种完全不同的原因。
整条链路上只有一个模型进程,启动时加载一次(约 8 秒),之后全程常驻。 音频以 16kHz / 单声道 / PCM16LE 这个统一格式贯穿始终 —— 浏览器、网络、Python、C++、Swift 原生进程 都认这一种格式,所以中间不需要任何转码环节。
系统一共有三种运行形态,共用同一套切块 / 账本 / 丢块逻辑,区别只在音源是谁:
| 形态 | 音源 | 入口 | 配音播放方 | 用途 |
|---|---|---|---|---|
| 麦克风同传 | 浏览器 getUserMedia | / → live.html | 浏览器 Web Audio | 自己说话,实时译出 |
| 视频同传 | sys-tap 抓系统声音 | /video | ttsplay 常驻进程 | 看任意播放器里的外语视频 |
| 离线灌文件 | wav 文件按实时节奏喂 | POST /api/simulate | 同上 | 没麦克风时验证整条链路 |
Hy-SimulMT-30B-A3B 是按轮次工作的模型:先听 1600ms,之后每 800ms 一轮,
每轮都要在听下一轮之前先作答。而官方 llama-mtmd-cli 只能读一个完整音频文件,
跑完就退出 —— 每次重新加载 18GB 权重约 8 秒,实时无从谈起。官方文档还明确写了
llama-server 跑不了这批 GGUF。
项目用三步把这件事打通,每一步都踩了一个具体的坑:
llama-server 认内嵌投影器
这批 GGUF 是单文件,音频塔(audio tower)已经打包进文本模型,所以正常情况下不需要
--mmproj。但 llama-server 判断"有没有多模态能力"只看
--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
/props 里 modalities.audio 变成 true,
一次能跑通整段音频(14.16s 音频 1.76s,约 8 倍实时)。但这条路最终没被采用——
原因就是改动二。
llama-server 走 OpenAI 消息协议,模板里给助手回合只发了
<|hy_Assistant|><think><>,
漏掉了 src_tgt checkpoint 必需的分支标记
溯源。少了它,源文分支会退化,实测输出直接变成重复片段:
"下一季度我们将重点提升下一季度我们将重点提升下一季度我们将重点提升…"
而且模板里根本没有"每轮重放助手头"这个概念 —— 硬套一定会失真。所以最终方案是绕开 server, 直接改造 CLI。
llama-mtmd-cli 加 --stream(+260 行)
这是本次最核心的改动。音频不再来自 --audio 文件,而是从 stdin 读裸 PCM16LE
单声道 16kHz,每凑满一轮就跑官方那套轮次逻辑,把结果按 JSON 行打到 stdout:
// live streaming variant: the same turn logic as run_turn_stream, but the audio
// arrives as raw PCM16LE mono 16 kHz on stdin instead of a file on disk, and
// every answered turn is reported as one JSON line on stdout.
//
// stdin blocking is what paces the pipeline: the loop will not start a turn
// until a full step of audio has been buffered, so the model never runs ahead
// of the speaker.
static int g_stream_first = 1600; // ms of audio in the opening turn
static int g_stream_step = 800; // ms of audio per following turn
<|hy_User|>、
每轮重放 <|hy_Assistant|><think></think>溯源。
这个 schedule 是训练时定死的,自己切块(比如 3.2 秒一刀)模型就会退化。
stdin 的阻塞读正好充当节拍器:凑不满一轮就不进推理,模型不会跑到说话人前面。
协议极简,三种消息:
{"type":"ready"}
{"type":"turn","i":0,"ms":1600,"src":"Good morning, everyone.","tgt":"大家早上好。","n_gen":11,"gen_ms":149.0,"pos":312}
{"type":"done","turns":17}
pos 是当前的上下文位置,Python 侧用它判断何时该换一个干净会话。
threading.Thread(target=self._drain_sync, daemon=True).start()
理解这个项目,关键是理解一次 decode 同时产出源文和译文。
模型不是先 ASR 再 MT 的两级流水线,而是在同一个生成序列里用
溯源 这个特殊 token 做分界:出现之前的部分是源文,之后是译文。
对应的常量定义在 live.py 顶部:
HEAD = "<|hy_begin▁of▁sentence|><|reasoning_mode|>reasoning_effort:no_think<|hy_User|>"
TAIL = "<__media__><|hy_Assistant|><think></think>溯源"
INSTR = {
"en-zh": "请完成下面的同声传译任务,将陆续收到的语音翻译成中文。"
"请结合上下文边听边译,输出能够确定的译文,不必等待整句结束。",
"zh-en": "Please act as a simultaneous interpreter. Translate the incoming speech "
"into English as you listen. Output what you are confident about instead of "
"waiting for the whole sentence to finish.",
}
注意 指令语种决定翻译方向。指令在首轮就被解码进上下文了,所以换方向必须重启模型进程 (约 8 秒)—— 这是"只能单向"这个边界的根因。
生成阶段的分流逻辑就一行判断:
llama.cpp-hyv3/tools/mtmd/mtmd-cli.cpp(--stream 新增)if (tok == t_tgt) {
in_tgt = true;
} else if (!llama_vocab_is_control(ctx.vocab, tok)) {
const std::string piece = common_token_to_piece(ctx.lctx, tok);
(in_tgt ? tgt : src) += piece; // 溯源 之前归源文,之后归译文
}
src 和 tgt 都空),
服务端会记一个 blank_turns。但"空"有两种:根本没听到(src 也空),
还是听到了没译出来(src 有内容)。源码对这两种分别打了不同的日志 ——
因为"中间少了半句"这个现象,只有区分开才能查。
live.py 有 1238 行,只用 Python 标准库 ——
连 WebSocket 的握手和帧编解码都是自己实现的(ws_accept_key / ws_frame /
ws_read,共 50 行)。这样整条链路不需要 pip install 任何东西。
| 角色 | 形态 | 职责 |
|---|---|---|
| HTTP 服务 | ThreadingHTTPServer 主线程 | 发页面、/api/*、升级 WebSocket |
| 模型进程 | 子进程 llama-mtmd-cli | 唯一重资产,stdin 收音频 / stdout 吐 JSON |
_reader | 线程 | 唯一解析译文的地方,逐行读 stdout |
Client.writer | 每客户端一个线程 | 异步写 WebSocket,避免慢客户端阻塞广播 |
watchdog | 线程 · 1.5s | 判链路静默、触发尾部结算 |
tts_worker ×2 | 线程 | 并行调用 say 合成 |
tts_emitter | 线程 | 严格按轮次顺序回传,保播放次序 |
_tap_reader | 线程(视频模式) | 读 sys-tap stdout,当第三个音源 |
Python 侧按 800ms 切块送进 stdin。C++ 首轮吃 1600ms(= 2 块)、之后每轮 1 块, 而 1600 是 800 的整数倍 —— 所以两边块边界天然对齐。这一点很重要: 只有对齐了,才敢整块丢弃;丢半块会让后面所有音频错位一格。
live.py · _drain()block = SAMPLE_RATE * BLOCK_MS // 1000 * 2 # 16000 * 800 / 1000 * 2 = 25600 字节
while len(self.stage) >= block:
chunk = bytes(self.stage[:block])
del self.stage[:block]
if self.sent_ms - self.done_ms > MAX_BACKLOG_MS: # 积压超限 → 整块丢掉追平
self.dropped_ms += BLOCK_MS
self.sent_ms += BLOCK_MS
continue
p.stdin.write(chunk); p.stdin.flush()
self.sent_ms += BLOCK_MS
三个 opcode 就够了:0x1 文本(控制指令)、0x2 二进制(PCM16)、
0x8 关闭,外加 0x9 ping 回 0xA pong。
客户端发来的控制指令很薄:ping(带字节账)、direction、
tts、source、interrupt、level、
device、rebuild。
send() 也不报错,
只是数据发进了黑洞。所以前端每 3 秒发一次 ping,
超过 9 秒没收到任何回执就判死重建,退避重试最多 30 次 ——
断网几秒就放弃的话,网络恢复了页面也还是死的。
这是整个项目最有工程价值的设计。"漏了一句"这种问题,靠听是查不出来的 —— 必须有一把统一的尺子,把浏览器采集、网络发送、服务端接收、模型处理四个环节放在同一个单位(毫秒)下对账。
换算关系固定:16kHz × 2 字节 = 32000 字节/秒 = 32 字节/毫秒,所以账本里到处是 // 32。
| 字段 | 含义 | 异常判据 |
|---|---|---|
client.gen_ms | 浏览器从麦克风采到的时长 | 不涨 → 采集侧断了 |
client.sent_ms | 浏览器真的发出去的时长 | 小于 gen → 发送缓冲在涨 |
recv_ms | 服务端收到的时长 | 不涨 → 网络 / 浏览器问题 |
model_ms | 已写进模型 stdin 的时长 | recv − model 应等于 stage |
done_ms | 模型已回报处理完的时长 | model − done 大 → 模型跟不上 |
stage_ms | 不足一块、卡在缓冲里的尾巴 | 停下 1.2 秒后自动结算 |
discard_ms | 本会话自己丢的(缓冲超 30 秒) | 应为 0 |
rejected_ms | 被别的窗口占用而拒收 | 不算本会话的账 |
backlog_ms | model_ms − done_ms 积压 | 持续 >2000 → 正在丢块追平 |
pos | 上下文位置 | 接近 13435 → 即将轮换会话 |
"实时"不是自然产生的,是四个机制共同兜出来的。每一个都对应一个具体会发生的失败。
积压 model_ms − done_ms 超过 10 秒,整块(800ms)丢掉追平实时。
宁可漏译一句,不能让延迟越滚越大 —— 这是"说几句就不动了"的直接原因之一。
停止说话 1.2 秒后,不足 800ms 的尾巴补静音凑成整块送出。 不补的话最后半句永远躺在缓冲区里等下一句,看着像卡住。
pos > CTX × 0.82(≈13435)时换一个干净进程,页面插一条分隔线。
16K 上下文按每 800ms 一涨,约 9–11 分钟轮换一次。
同一时刻只接受一个音源(两路写同一个 stdin 会互相插帧)。 第一个开口的独占,租约 4 秒 —— 占用者 4 秒没送数据就自动让出。
block_rms 区分"真没声"和"有声但不译"。
SILENT_RMS = 1e-3 这个阈值几乎不会误判,
并且要连静音 4 秒才提醒 —— 正常停顿不会被误报。
前端 live.html 是单文件、零外部依赖。它最要紧的一条设计是:
采集和播放必须是两个独立的 AudioContext。
采集用 AudioWorkletNode(处理器名 tap),源码以字符串形式内联、用 Blob URL 注册,
因此保持单文件零依赖。它每攒满 1024 帧就 postMessage 一次:
class Tap extends AudioWorkletProcessor{
constructor(){ super(); this.buf=new Float32Array(1024); this.n=0; }
process(inputs){
var ch=inputs[0]&&inputs[0][0];
if(ch){ for(var i=0;i<ch.length;i++){
this.buf[this.n++]=ch[i];
if(this.n===1024){ this.port.postMessage(this.buf); this.buf=new Float32Array(1024); this.n=0; }
}}
return true;
}
}
优先让浏览器自己的重采样器去做(它有抗混叠滤波);代码里保留了一份纯线性插值的
Resampler 作为 fallback,并明确注释了代价:
44.1k→16k 会把 8k 以上的能量折叠进语音频段,听起来没差,但模型的识别率会明显下降。
关闭 Chrome 的 AGC 后,内置麦克风阵列送来的电平可能低到 −60 dBFS —— 人耳觉得"我明明在说话", 模型那边几乎是静音。所以自己实现了慢速包络归一化:
agcEnv = max(peak, agcEnv × 0.995),门限 8e-5(刚好在数字静音之上)min(150, 0.22 / agcEnv),用 +(want−gain) × 0.08 平滑跟随sendPCM() 先清空缓冲再判断有没有送出去,等于把这 100ms 的话直接删了 ——
连接抖一下、切个方向,用户听到的就是"中间少了一句"。
现在的顺序是真的送出去了才清空,送不出去就攒着(最多 12 秒),
恢复后一次性补发。代价远小于凭空吞掉半句话。
译音不是"来一段播一段",而是用 S.nextTime 单调递增地在 Web Audio 时间轴上排队,
段间留 15ms 间隙,落后超过 0.5 秒就重新对齐。同时用一个
requestAnimationFrame 循环把当前时间段对应的字幕行标为 .playing ——
播放高亮是跟着 AudioContext 时钟走的,不是跟着"刚收到哪条"走的。
TTS 用 macOS 原生 say(中文 Tingting / 英文 Ava Enhanced),
实测 RTF≈0.11。但它有个要命的特点:每次调用有约 0.85 秒的固定启动开销,
跟文本长度几乎无关 —— 一个字 0.868s,七秒长的文本 0.916s。
而一轮音频才 800ms。串行合成会刚好卡在临界点上,说话人一旦不停就会越积越多。 解法是 2 个 worker 并行合成 + 1 个 emitter 严格按序回传:
live.py · tts_emitter()# 严格按轮次顺序把音频推给页面,避免并行合成打乱播放次序
while True:
with self.tts_cv:
while not self.tts_order: self.tts_cv.wait(1.0)
i = self.tts_order[0]
waited = 0.0
while i not in self.tts_done:
self.tts_cv.wait(0.3); waited += 0.3
if waited > 3.0: # 音频晚 3 秒就没意义了
self.tts_done[i] = (None, 0) # 超时跳过,不能堵死后面所有轮次
path, dur = self.tts_done.pop(i)
self.tts_order.pop(0)
say 会把语音资源加载三份,本机 swap 紧张时反而互相拖死。
2 个已经能跑在到达速度之上:0.85s / 2 ≈ 0.43s 每轮,小于 800ms 的到达间隔。
| 方案 | RTF | 音质 | 适用性 |
|---|---|---|---|
say(默认) | ≈0.11 | 系统合成音,音色固定 | 实时可用 |
| Qwen3-TTS-0.6B 克隆 | ≈1.3 | 复刻原说话人音色 | 只适合事后听 |
| Eloquence 系 | 快 0.16s | 音质差一档 | 压不下固定开销 |
那 1.15 秒的"说话→出声"里,约 0.9 秒是 say 的固定启动开销。
换语音压不下来,要做到更低必须用进程内合成(AVSpeechSynthesizer),那是另一件事。
看视频场景不需要麦克风 —— 用 native/sys-tap 直接抓
系统里正在播放的任何声音(浏览器视频、QuickTime 都行),
翻译后由 native/ttsplay 常驻进程念出来。前提:macOS 14.4+,
且运行本服务的终端已在「隐私与安全性 → 屏幕与系统音频录制」里授权。
Swift 直接调 CoreAudio 私有 API,四步:
CATapDescription 用 stereoGlobalTapButExcludeProcesses,
AudioHardwareCreateProcessTap;muteBehavior = .mutedWhenTappedAudioHardwareCreateAggregateDevice,UUID 随机,不污染系统设备列表write() 失败就丢,不在实时线程里死等stdin : [4 字节大端长度 N][N 字节 WAV] N = 0 → 清空队列并停止当前播放
stdout: 每播完一条打印一行 "done" 供服务端对账(player_done)
EOF : 播完队列里剩下的再退出
--exclude-pid 会落空(实测每次都"未排除")。后果有二:
① 译文被 mutedWhenTapped 一起静音,用户什么都听不到;
② 译文被 tap 抓回去喂模型(自激)。
解法是 _prime_player():先喂 2 秒 440Hz、振幅 80/32767 的近静音(人耳不可闻),
等它注册成音频进程后再创建 tap。
模型每 800ms 吐一轮译文,连续轮次顺序连播才是完整句子,所以平时
不逐轮打断 —— 只在积压 ≥4 轮(明显跟不上)时自动清队,或手动点「打断播报」
(给 ttsplay 写一个 len=0 帧)。
「说了几句就没反应」有几种完全不同的原因,过去都表现为界面无任何提示, 只能靠猜。现在每种都有自己的信号 —— 这是这个项目在工程上最见功力的部分。
| 界面提示 | 含义 | 触发来源 | 自动恢复 |
|---|---|---|---|
已 N 秒没收到声音 | 服务端确认音频字节数不再增长,链路真的断了 | watchdog · stall_sec ≥ 6 | 是 前端随后判死重连 |
连接中断,正在重连… | 心跳超 9 秒没收到任何回执(TCP 半开) | 前端 heartbeat | 是 退避重试最多 30 次 |
麦克风没有声音输入 | 页面 4 秒没收到 AudioWorklet 的帧 | 前端 lastPcmAt | 是 resume() / 重建 |
麦克风听不到声音 | 字节在涨但能量接近 0 —— 设备还在跑,只是没声音进来 | 服务端 silent_sec ≥ 4 | 需人工 查设备/权限 |
另一个窗口正在占用麦克风 | 同时开了多个同传页面,服务端只认第一个开口的 | 服务端租约拒绝 | 是 4 秒后自动接管 |
| 计时在走、无提示、无新字幕 | 模型在推理,或音频不足一块 | — | 正常等待 |
level)和轨道状态
(muted / readyState / context.state)上报到服务端;
把实际使用的输入设备名显式回显(macOS 上极容易选到虚拟声卡,送来的是数字静音而界面看不出来);
甚至提供「测一下麦克风」按钮 —— 录 4 秒、报告电平、然后原样放给你听。
另外还有一个诊断钩子,供自动化验证与排障使用:
web/live.htmlwindow.__diag() // → running / ws / audioCtx / rows / withAudio / scheduled /
// playing / sampleRate / micRate / genMs / sentMs / dropMs /
// sendFail / recvAge / warn
环境:M2 Max / 64GB / macOS,模型 Q4_K_M,Metal 后端。
| 指标 | 数值 | 备注 |
|---|---|---|
| 模型加载(冷启) | ≈ 8 s | 页缓存热后 2 s |
| 单轮生成(800ms 音频) | 115 – 235 ms | 远小于到达间隔 |
| 端到端:说话 → 字幕 | 平均 255 ms | ≈ 生成时间 + 传输开销 |
| 端到端:说话 → 出声 | 平均 1150 ms | 其中约 900ms 是 say 固定开销 |
| 14.16s 英文音频 | 8 轮字幕 + 8 段配音 | 字幕总延迟平均 255ms |
| 连续同传压测 | 66s → 82 轮 | 积压恒定 0–800ms,丢弃 0 |
| 浏览器全链路 | 96s 连续 | 87 行字幕 / 86 段配音,无中断 |
| KV 缓存 | 96 KB/token | 16K 上下文约 1.5 GB |
服务启动时还会把麦克风原始音频默认落盘(HY_DUMP,16k 单声道 16bit ≈ 115MB/小时)。
出事时能把"用户到底说了什么"直接放出来听,不用再靠猜 —— 注意只落盘真的进了模型的音频,
以前连被拒的也一起落,排查时听到的是模型根本没听过的东西,反而被带偏。
| 边界 | 根因 / 取舍 |
|---|---|
| 只能单向,换方向要重启(≈8s) | 翻译方向由指令语种决定,而指令在首轮就被解码进上下文。改不了,只能重启。 |
| 会话约 9–11 分钟后自动轮换 | 上下文固定 16K。开 32K 多占 1.6GB,本机 swap 已吃 15GB+,模型页一旦被换出推理就失去实时性 —— 省内存比拉长会话更值。 |
| 同一时刻只接受一个音源 | 两路音频写进同一个 stdin 会互相插帧,模型就听不懂了。用 4 秒租约防止断掉的连接永久锁死。 |
| 落后超过 10 秒会整块丢弃 | 刻意定得比"还能听"的界限宽:丢块会让译文凭空少一句,而用户要的是"把我说的都译出来"。真跟不上就让延迟暴露出来,别偷偷吞内容。 |
| TTS 音色固定 | say 方案,中文 Tingting / 英文 Ava Enhanced。改 TTS_VOICE 即可。 |
--tts qwen 跟不上 |
RTF≈1.3,配音会持续落后于说话人,只适合事后听。 |
| 外放时可能自译自 | 模型会听到自己念出的译文然后把这句译文当新输入再译一遍。建议戴耳机;浏览器侧开了 AEC 缓解。 |
| 文件 | 作用 |
|---|---|
live.py | 同传服务:模型进程管理、WebSocket、TTS、HTTP、系统声音模式 |
live.sh | 启动入口(--qwen 切音色克隆,PORT= 换端口) |
web/live.html | 麦克风同传界面(单文件,零外部依赖) |
web/video.html | 视频同传监控台(系统声音模式,单文件) |
web/index.html | 早期版本:上传音频文件 / 录音后离线处理(配 server.py + Qwen3-TTS 克隆) |
native/sys-tap.swift | 全局系统音频采集(CoreAudio 进程 tap → stdout 裸 PCM) |
native/ttsplay.swift | 常驻译文播放器(stdin 喂 WAV,len=0 打断,可按 PID 排除) |
llama.cpp-hyv3/ | 已编译的 llama-mtmd-cli(含 --stream 改动,tools/mtmd/mtmd-cli.cpp +260 行) |
live-cache/ | 合成出的 WAV,超过 400 条按批淘汰最旧的 |
live-model.log | 模型进程的 stderr |
POST /api/simulate?realtime=1 —— 把一段 wav 当作麦克风输入灌进去,用于没有麦克风时验证整条链路;
POST /api/restart —— 重启模型进程;
GET /api/status —— 看当前状态(含完整音频账本)。