源码级技术分析

Hy-SimulMT 本地实时同传
是怎么跑起来的

一个 30B-A3B 的语音同传模型,官方工具只能吃「一个完整音频文件」,每次调用重新加载 18GB 权重。 这个项目改了 llama.cpp 的 260 行 C++,把它变成一个常驻的流式进程,再配一层零依赖的 Python 服务和一个 双 AudioContext 隔离的浏览器前端,把端到端字幕延迟压到 255ms —— 全部在本机,不联网。

分析对象
hy-simulmt/
前端
web/live.html · web/video.html
服务
live.py · 1238 行
模型
Hy-SimulMT-30B-A3B · Q4_K_M
平台
macOS · M2 Max / 64GB · Metal
一句话原理

模型进程常驻 + stdin 当节拍器 + 800ms 固定分轮。 Python 服务把浏览器送来的 PCM 按 800ms 切成块,写进一个长跑的 llama-mtmd-cli --stream 进程的 stdin;这个进程凑满一块就跑一轮推理, 把「源文 + 译文」以 JSON 行打回 stdout。stdin 的阻塞读天然保证了模型不会跑到说话人前面, 而分轮 schedule(1600ms 首轮 + 800ms 每轮)完全复用官方实现 —— 因为它是训练时定死的,自己切块模型就会退化。

剩下的工程量几乎全在可观测性与自愈上:音频账本、六类故障的独立信号、 麦克风断流自动重建、空轮自动重启会话。因为「界面没反应」这一个症状,背后有六种完全不同的原因。

01全链路数据流

整条链路上只有一个模型进程,启动时加载一次(约 8 秒),之后全程常驻。 音频以 16kHz / 单声道 / PCM16LE 这个统一格式贯穿始终 —— 浏览器、网络、Python、C++、Swift 原生进程 都认这一种格式,所以中间不需要任何转码环节。

去程 · 音频 麦克风 getUserMedia AudioWorklet 1024 帧 → 16k Int16 WebSocket 二进制帧 · 100ms/包 live.py stage 缓冲 · 800ms 切块 stdin 裸 PCM 写入 llama-mtmd-cli --stream 常驻进程 · 模型只加载一次 凑满一块才推理(stdin 阻塞) 回程 · 译文 字幕(JSON turn) WebSocket → 立刻显示 延迟 ≈ 255ms 配音(WAV) 2×say 并行 → 顺序排队 出声 ≈ 1150ms 统一格式:16 kHz · 单声道 · PCM16LE · 32 字节/毫秒 —— 全链路无转码
图 1去程与回程。模型进程是唯一的重资产,音频格式在全链路保持一致,这是能做到低延迟的前提之一。

系统一共有三种运行形态,共用同一套切块 / 账本 / 丢块逻辑,区别只在音源是谁:

形态音源入口配音播放方用途
麦克风同传浏览器 getUserMedia/ → live.html浏览器 Web Audio自己说话,实时译出
视频同传sys-tap 抓系统声音/videottsplay 常驻进程看任意播放器里的外语视频
离线灌文件wav 文件按实时节奏喂POST /api/simulate同上没麦克风时验证整条链路

02模型层:三处改动打通实时

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 倍实时)。但这条路最终没被采用—— 原因就是改动二。

改动二:不走 server 的 chat 模板

llama-server 走 OpenAI 消息协议,模板里给助手回合只发了 <|hy_Assistant|><think><>, 漏掉了 src_tgt checkpoint 必需的分支标记 溯源。少了它,源文分支会退化,实测输出直接变成重复片段:

"下一季度我们将重点提升下一季度我们将重点提升下一季度我们将重点提升…"

而且模板里根本没有"每轮重放助手头"这个概念 —— 硬套一定会失真。所以最终方案是绕开 server, 直接改造 CLI。

改动三:给 llama-mtmd-cli 加 --stream(+260 行)

这是本次最核心的改动。音频不再来自 --audio 文件,而是从 stdin 读裸 PCM16LE 单声道 16kHz,每凑满一轮就跑官方那套轮次逻辑,把结果按 JSON 行打到 stdout:

llama.cpp-hyv3/tools/mtmd/mtmd-cli.cpp(本地 diff,+260 行)
// 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
关键点
完全复用官方分块逻辑:1600ms 首轮 + 800ms 每轮、每轮之间补 <|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 侧用它判断何时该换一个干净会话。

一个隐藏的死锁
模型就绪时需要补送加载期间积压的音频,但补送要往 stdin 写(可能阻塞), 而当前线程正在读 stdout —— 卡在这里就没人消费模型输出,直接互锁。 源码里的解法是必须换线程做补送: threading.Thread(target=self._drain_sync, daemon=True).start()

03分轮 schedule 与 prompt 结构

理解这个项目,关键是理解一次 decode 同时产出源文和译文。 模型不是先 ASR 再 MT 的两级流水线,而是在同一个生成序列里用 溯源 这个特殊 token 做分界:出现之前的部分是源文,之后是译文。

模型侧分轮节奏 turn 0 · 1600ms 800ms 800ms 800ms 800ms … 直到 stdin EOF 0 1.6s 2.4s 3.2s 4.0s 每轮的生成(115–235ms,远小于 800ms) 生成完立刻回 JSON,然后等下一块 每轮送进上下文的 token 序列 pre(指令 + 用户头) 音频 chunk post(助手头 + 溯源) src → 溯源 → tgt turn>0 时前面还要补一个 <|hy_User|>
图 2分轮节奏与 token 拼装。首轮 1600ms 是官方 schedule 定死的,之后每 800ms 一轮;1600 是 800 的整数倍,所以 Python 与 C++ 两侧的块边界天然对齐。

对应的常量定义在 live.py 顶部:

live.py:73-79
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 有内容)。源码对这两种分别打了不同的日志 —— 因为"中间少了半句"这个现象,只有区分开才能查。

04服务层:零依赖 live.py

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 与 C++ 的边界对齐

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

WebSocket:只实现服务端需要的最小子集

三个 opcode 就够了:0x1 文本(控制指令)、0x2 二进制(PCM16)、 0x8 关闭,外加 0x9 ping 回 0xA pong。 客户端发来的控制指令很薄:ping(带字节账)、direction、 tts、source、interrupt、level、 device、rebuild。

为什么需要应用层心跳
TCP 半开连接浏览器自己是发现不了的:连接没断,send() 也不报错, 只是数据发进了黑洞。所以前端每 3 秒发一次 ping, 超过 9 秒没收到任何回执就判死重建,退避重试最多 30 次 —— 断网几秒就放弃的话,网络恢复了页面也还是死的。

05音频账本:一把尺子量全链路

这是整个项目最有工程价值的设计。"漏了一句"这种问题,靠听是查不出来的 —— 必须有一把统一的尺子,把浏览器采集、网络发送、服务端接收、模型处理四个环节放在同一个单位(毫秒)下对账。

同一把尺子 · 单位毫秒 · 同一会话口径 client.gen_ms 麦克风采到的 client.sent_ms 真的发出去的 recv_ms 服务端收到 model_ms 写进 stdin done_ms 模型回报处理完 stage_ms 还不足一块的尾巴 discard_ms 缓冲超限,本会话自己丢的 rejected_ms 被别的窗口占用而拒收 判据:recv ≈ model ≈ done,且 discard_ms = 0;recv − model 应当正好等于 stage_ms
图 3音频账本。实测(48kHz 原生语音,11 秒):recv 39186 / model 38400 / done 38400 / stage 786 / discard 0 —— 差额 786ms 与 stage_ms 完全吻合。

换算关系固定:16kHz × 2 字节 = 32000 字节/秒 = 32 字节/毫秒,所以账本里到处是 // 32。

GET /api/status 里的账本字段
字段含义异常判据
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_msmodel_ms − done_ms 积压持续 >2000 → 正在丢块追平
pos上下文位置接近 13435 → 即将轮换会话

06实时性的四道闸门

"实时"不是自然产生的,是四个机制共同兜出来的。每一个都对应一个具体会发生的失败。

① 丢块追平 宁可漏译

积压 model_ms − done_ms 超过 10 秒,整块(800ms)丢掉追平实时。 宁可漏译一句,不能让延迟越滚越大 —— 这是"说几句就不动了"的直接原因之一。

② 尾部结算 补齐半句

停止说话 1.2 秒后,不足 800ms 的尾巴补静音凑成整块送出。 不补的话最后半句永远躺在缓冲区里等下一句,看着像卡住。

③ 上下文轮换 避免静默截断

pos > CTX × 0.82(≈13435)时换一个干净进程,页面插一条分隔线。 16K 上下文按每 800ms 一涨,约 9–11 分钟轮换一次。

④ 音源租约 防永久锁死

同一时刻只接受一个音源(两路写同一个 stdin 会互相插帧)。 第一个开口的独占,租约 4 秒 —— 占用者 4 秒没送数据就自动让出。

还有两处自动自愈

RMS 阈值是怎么定的
正常人说话 RMS 在 0.01~0.3 量级,而数字静音(设备被拆掉、轨道被 mute)是精确的 0, 两者差着几个数量级。所以 SILENT_RMS = 1e-3 这个阈值几乎不会误判, 并且要连静音 4 秒才提醒 —— 正常停顿不会被误报。

07浏览器层:双 AudioContext

前端 live.html 是单文件、零外部依赖。它最要紧的一条设计是: 采集和播放必须是两个独立的 AudioContext。

S.micAc · 采集上下文 终点是内存哑终端,绝不碰硬件输出 MediaStream AudioWorklet tap MediaStreamDestination(哑终端,只活在内存里) S.ac · 播放上下文 接 destination,负责译音排队播放 fetch WAV decode gain S.ac.destination(真实扬声器输出) 隔 离 macOS 上麦克风和扬声器通常挂在同一个 AudioUnit 里。只要这个 AudioUnit 上有输出开始播, 它就从「纯输入」切到「双向」模式,采集流会被拆掉而 Chrome 往往补不回来 —— 表现就是「第一句译得挺好,配音一响,此后彻底聋掉」。
图 4两个 AudioContext 时钟各自独立、互不牵连。这是整个前端最关键的一条架构决定。

AudioWorklet:为什么不用 ScriptProcessor

采集用 AudioWorkletNode(处理器名 tap),源码以字符串形式内联、用 Blob URL 注册, 因此保持单文件零依赖。它每攒满 1024 帧就 postMessage 一次:

web/live.html · WORKLET
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;
  }
}

重采样与软件 AGC

优先让浏览器自己的重采样器去做(它有抗混叠滤波);代码里保留了一份纯线性插值的 Resampler 作为 fallback,并明确注释了代价: 44.1k→16k 会把 8k 以上的能量折叠进语音频段,听起来没差,但模型的识别率会明显下降。

关闭 Chrome 的 AGC 后,内置麦克风阵列送来的电平可能低到 −60 dBFS —— 人耳觉得"我明明在说话", 模型那边几乎是静音。所以自己实现了慢速包络归一化:

发送缓冲:决不静默吞掉半句话

一个真实的 bug
以前 sendPCM() 先清空缓冲再判断有没有送出去,等于把这 100ms 的话直接删了 —— 连接抖一下、切个方向,用户听到的就是"中间少了一句"。 现在的顺序是真的送出去了才清空,送不出去就攒着(最多 12 秒), 恢复后一次性补发。代价远小于凭空吞掉半句话。

播放排队与高亮

译音不是"来一段播一段",而是用 S.nextTime 单调递增地在 Web Audio 时间轴上排队, 段间留 15ms 间隙,落后超过 0.5 秒就重新对齐。同时用一个 requestAnimationFrame 循环把当前时间段对应的字幕行标为 .playing —— 播放高亮是跟着 AudioContext 时钟走的,不是跟着"刚收到哪条"走的。

08TTS:并行合成 + 顺序回传

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)
为什么是 2 个 worker 而不是 3 个
3 个并发 say 会把语音资源加载三份,本机 swap 紧张时反而互相拖死。 2 个已经能跑在到达速度之上:0.85s / 2 ≈ 0.43s 每轮,小于 800ms 的到达间隔。
TTS 方案对比
方案RTF音质适用性
say(默认)≈0.11系统合成音,音色固定实时可用
Qwen3-TTS-0.6B 克隆≈1.3复刻原说话人音色只适合事后听
Eloquence 系快 0.16s音质差一档压不下固定开销

那 1.15 秒的"说话→出声"里,约 0.9 秒是 say 的固定启动开销。 换语音压不下来,要做到更低必须用进程内合成(AVSpeechSynthesizer),那是另一件事。

09视频同传:系统声音与自激

看视频场景不需要麦克风 —— 用 native/sys-tap 直接抓 系统里正在播放的任何声音(浏览器视频、QuickTime 都行), 翻译后由 native/ttsplay 常驻进程念出来。前提:macOS 14.4+, 且运行本服务的终端已在「隐私与安全性 → 屏幕与系统音频录制」里授权。

系统声音模式 · 自激环路必须被切断 任意播放器 浏览器 / QuickTime sys-tap CoreAudio 进程 tap 模型 → 译文 经 live.py 中转 ttsplay PID 固定,已被排除 扬声器 若不排除:译文被 tap 抓回去再译一遍 → 自激啸叫 --exclude-pid 监控页 video.html 绝不播放任何声音 —— 浏览器也在 tap 范围里,播了就自激 启动顺序不能反:先起 ttsplay 拿到固定 PID → 再创建 tap 并 --exclude-pid 排除它 原声:mutedWhenTapped 让 tap 激活期间原声自动静音,停止接管后系统自动恢复,不会把电脑搞哑
图 5自激环路与切断点。这是视频模式唯一真正困难的地方。

sys-tap 的实现路径

Swift 直接调 CoreAudio 私有 API,四步:

  1. 建 tap:CATapDescription 用 stereoGlobalTapButExcludeProcesses, AudioHardwareCreateProcessTap;muteBehavior = .mutedWhenTapped
  2. 建私有聚合设备:AudioHardwareCreateAggregateDevice,UUID 随机,不污染系统设备列表
  3. IOProc 回调:多缓冲/多声道统一混成 mono → 线性插值重采样 48k→16k(跨块保持相位)→ s16le
  4. 写 stdout:write() 失败就丢,不在实时线程里死等

ttsplay 的管道协议

stdin : [4 字节大端长度 N][N 字节 WAV]        N = 0 → 清空队列并停止当前播放
stdout: 每播完一条打印一行 "done"             供服务端对账(player_done)
EOF   : 播完队列里剩下的再退出
一个"未出声就不会被排除"的坑
没发过声的进程不在系统音频进程列表里,创建 tap 时 --exclude-pid 会落空(实测每次都"未排除")。后果有二: ① 译文被 mutedWhenTapped 一起静音,用户什么都听不到; ② 译文被 tap 抓回去喂模型(自激)。 解法是 _prime_player():先喂 2 秒 440Hz、振幅 80/32767 的近静音(人耳不可闻), 等它注册成音频进程后再创建 tap。

打断语义

模型每 800ms 吐一轮译文,连续轮次顺序连播才是完整句子,所以平时 不逐轮打断 —— 只在积压 ≥4 轮(明显跟不上)时自动清队,或手动点「打断播报」 (给 ttsplay 写一个 len=0 帧)。

10故障分诊:六种「没反应」

「说了几句就没反应」有几种完全不同的原因,过去都表现为界面无任何提示, 只能靠猜。现在每种都有自己的信号 —— 这是这个项目在工程上最见功力的部分。

症状 → 含义 → 是否自动恢复
界面提示含义触发来源自动恢复
已 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.html
window.__diag()  // → running / ws / audioCtx / rows / withAudio / scheduled /
                 //   playing / sampleRate / micRate / genMs / sentMs / dropMs /
                 //   sendFail / recvAge / warn

11实测数据

环境: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/token16K 上下文约 1.5 GB
参照系
专业同传的滞后通常在 2–3 秒。这套系统的字幕延迟是 255ms, 出声延迟 1.15 秒 —— 已经落在同数量级甚至更快。

服务启动时还会把麦克风原始音频默认落盘(HY_DUMP,16k 单声道 16bit ≈ 115MB/小时)。 出事时能把"用户到底说了什么"直接放出来听,不用再靠猜 —— 注意只落盘真的进了模型的音频, 以前连被拒的也一起落,排查时听到的是模型根本没听过的东西,反而被带偏。

12已知边界与设计取舍

边界与背后的取舍
边界根因 / 取舍
只能单向,换方向要重启(≈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 —— 看当前状态(含完整音频账本)。