ARCHITECTURE DEEP-DIVE · 2026

回响 EchoNote
本地实时语音同传的解剖报告

把系统声音、麦克风或本地音视频,变成一条可回读、可导出的双语会话时间线。Rust Core 负责会话与编排,Swift 原生进程接管 macOS 音频,常驻 Hy-SimulMT 模型做同传翻译。

1 个常驻模型
全局互斥 · attach / adopt
100ms → 800ms
传输帧 → 模型块对齐
3 条隔离键
session_id + epoch + sequence
65% / 80%
上下文轮换双阈值
01 — THE BIG PICTURE

一张图看懂全系统

依赖方向自上而下:simulmt-app → simulmt-core → simulmt-policy / simulmt-api。Core 通过 trait 与子进程隔离音源、翻译器、播放器,HTTP 层不感知任何 macOS 或模型细节。

web/index.html — 浏览器控制台 单文件 · 原生 HTML/CSS/JS · 零构建 · localStorage 仅存界面偏好 apps/desktop — WKWebView 桌面壳 Swift · 仅加载本地 URL + 原生媒体选择桥接 apps/core — Axum HTTP 入口(651 行) REST 会话控制 · 每会话 WebSocket 事件流 · 媒体导入(afconvert) · SRT/VTT/WAV 导出 · 静态托管 · 仅监听本机 crates/core — SessionManager / Session(编排中心,session.rs 3065 行) 生命周期门控 · 全局模型互斥 detach/adopt · 事件广播 · 指标 · JSONL 字幕持久化 · epoch 隔离 Idle → Preparing → Ready → Running ↔ Paused → Stopping · Ready = 模型常驻未采集 RunningSource 100ms 帧 · 16kHz PCM16 AudioChunker 拼帧 → 800ms 块 RunningTranslator llama-mtmd-cli --stream-v2 SubtitleAssembler 字幕组句 → 历史 / 导出 / WebSocket SpeechAssembler(TTS 组句,独立阈值) 合成 → 播放队列 → 播放事件 / 指标 audio_host.swift(子进程) Core Audio 进程/全局 Tap · mutedWhenTapped 原声静音 AVAudioEngine 麦克风 · 排除播放器 PID 防回采 stdout 输出 3200 字节 PCM · discontinuity 标记断点 llama-mtmd-cli(常驻模型) stdin:SMT2 头二进制音频帧 stdout:逐行 JSON 翻译事件 上下文 65% 轮换 / 80% 强制 · 未确认音频重放 tts_host.swift(子进程) AVSpeechSynthesizer 合成 24kHz PCM16 WAV AVAudioPlayer 精确时刻接续播放 · rate 变速 stdin/stdout 逐行 JSON 命令与真实播放事件
基座:crates/api(simulmt.api.v1 契约 · serde 共享类型 · Proto 版本化) crates/policy(AudioChunker · BoundedQueue · 字幕组句) | runtime/(sessions/ · subtitles/ · media/ · 模型日志 · TTS 缓存,均不入库) vendor/ llama.cpp-hyv3 + Hy-SimulMT-30B GGUF(18.4 GiB)
02 — MODULE ANATOMY

每个模块:职责 · 技术 · 原理

全仓约 8.4k 行源码,Rust 为骨架,Swift 做 macOS 原语,前端单文件零依赖。

RUST · 契约层

crates/api — 共享类型契约

crates/api/src/lib.rs
344 行 · 被所有 crate 依赖

系统的“通用语言”。会话状态机、音频帧、翻译/字幕/播放事件、指标、快照全部在此定义一次,serde 双向序列化,并同步维护 api/proto/simulmt/v1 六份 Proto 契约供外部 Worker 使用。

  • 隔离键:session_id + epoch + sequence,旧代际数据一律丢弃
  • 音频归一:16 kHz / 单声道 / PCM S16LE,单调时钟纳秒、微秒时长
RUST · 策略层

crates/policy — 帧对齐与背压

crates/policy/src/lib.rs + subtitle.rs
620 行 · 无 IO 纯逻辑

把音源吐出的 100ms 传输帧拼成模型要求的 800ms 块;BoundedQueue 实现有界队列与溢出策略(拒绝 / 丢最旧),积压统计直接喂给三档语音策略的“跳句”决策。

  • AudioChunker:缓冲拼帧,finish() 冲刷尾块
  • 字幕组句在 policy,TTS 组句在 core/output.rs,两者阈值独立
RUST · 编排核心

crates/core — SessionManager

crates/core/src/session.rs
3065 行 · 全仓最大文件

编排中心。管理多会话与全局模型互斥:prepare 时把其他会话的常驻模型 detach/adopt 过来(reattach 重定向事件、清音频与语言上下文),权重零重复加载。每次 start/resume 创建新运行代际(epoch)。

  • 生命周期:模型启停与采集启停完全分离
  • 字幕 JSONL:追加写、按 cue_id 折叠修订,内存 512 条 / 磁盘全量
  • 崩溃语义:重启只恢复配置+元数据+字幕,运行态一律回 Idle
RUST · 会话编排

crates/core — 数据面四件套

source.rs · translator.rs · output.rs · lifecycle.rs
483 + 1150 + 790 + 103 行

数据通路:Source → Chunker → Translator → 字幕/TTS 双组句。lifecycle.rs 用 Mutex + CancellationToken 串行化生命周期操作,stop 可取消当前持有门的操作,高频音频路径绝不经过它。

  • source.rs:mock / WAV 文件 / macOS 子进程音源,宿主掉线自动重连
  • translator.rs:跟踪未确认音频,恢复时重放;片段 ID 连续
  • output.rs:SpeechAssembler 组句后驱动 tts_host,收真实播放事件
RUST · HTTP 层

apps/core — Axum 应用入口

apps/core/src/main.rs
651 行 · REST + WS + 静态托管

薄薄一层胶水:16 条会话路由(prepare/start/pause/resume/stop/stop-model…)、每会话 WebSocket 事件流、媒体上传经 afconvert 解码为 16kHz WAV、SRT/VTT/WAV 导出。默认只监听 127.0.0.1:8780。

  • 路由即文档:新增能力 = API 类型 + Core 方法 + 路由 + 前端事件
  • 媒体上限 1 GiB,可解码范围受 macOS 解码器限制
SWIFT · 音频采集

adapters/audio — audio_host

adapters/audio/macos/audio_host.swift
471 行 · 常驻子进程

macOS 音频原语封装。CATapDescription 建进程 Tap 或全局立体声 Tap(可排除指定 PID),muteBehavior = .mutedWhenTapped 实现“接管即静音原声”;麦克风走 AVAudioEngine。stdout 只写裸 PCM,日志一律 stderr。

  • 3200 字节/帧 PCM 上行 · 异常退出 Core 重连 + discontinuity
  • 独立播放器排除:译文播放不进采集,杜绝回采循环
SWIFT · 语音输出

adapters/sinks — tts_host

adapters/sinks/native-player/tts_host.swift
473 行 · 常驻子进程

“可预合成、按精确时刻接续”的原生播放器。AVSpeechSynthesizer 生成 24kHz PCM16 WAV 缓存;多 AVAudioPlayer 用 play(atTime:) 设备时钟无缝排播,支持 rate 变速与三档策略联动。

  • 命令协议:stdin/stdout 逐行 JSON(合成/播放/暂停/flush/策略)
  • 真实事件回传:Core 据播放事件高亮当前句、算指标
WEB · 前端

web/index.html — 单文件控制台

web/index.html
57 KB 单文件 · 零 npm 依赖 · 零构建

纸感设计的多会话工作台:左侧会话列表、中间双语时间线(真实 TTS 播放事件高亮当前句)、底部控制条(音源/方向/三档策略/模型)。localStorage 只存界面偏好,权威数据全在后端。

  • 窗口不滚动:仅会话列表与时间线局部滚动
  • 刷新即恢复:能发现已启动模型,绝不重复加载
PROTO · 外部契约

api/proto — 版本化契约

api/proto/simulmt/v1/*.proto
6 份:common · session · audio · translation · playback · catalog

为外部 Worker 准备的版本化 Protobuf 契约,与 Rust serde 类型一一对应。改任一侧必须同步另一侧——这是跨语言协作的防漂移锁。

03 — RUNTIME MECHANICS

让同传可靠起来的六个机制

同传系统的难点不是翻译,而是时间:旧数据要丢弃、积压要泄洪、长会话不爆上下文、崩溃要能接上。

代际隔离 — epoch + source_generation

每次 start/resume 递增 epoch,每次音源重置递增 source_generation。管线中所有帧与结果都携带三元组 session_id + epoch + sequence,旧代际的翻译与播放结果到达即丢弃——晚到的旧数据不会污染新会话。

双上下文轮换 — 65% / 80%

上下文占用到 65% 时在空白回合轮换、80% 强制轮换,只携带最近 4 轮双语短上下文。优先进程内轮换(片段 ID 连续),不支持才重启进程;轮换与异常恢复都会重放已写入未确认的音频,一个音节不丢。

三档语音策略

跟得紧:短组句、低缓冲、积极追平,严重积压丢最旧语音(字幕保留)。自然流畅:连续调度、温和追平。完整优先:充分组句、原速播放、不跳句。策略同时作用于组句阈值与播放队列,运行中可切。

模型全局互斥 — 一个常驻进程

全局只保一个常驻 llama-mtmd-cli --stream-v2。会话切换时不杀进程,而是 reattach:重定向 stdout 事件流、清空音频缓冲与语言上下文。18 GiB 权重秒级“搬家”。翻译方向切换才重启进程。

防回采闭环

系统/应用 Tap 创建时把 TTS 播放器 PID 加入排除列表,译文的合成与播放声音永远不会被采回去再翻译一次;暂停/停止即销毁 Tap、恢复原声,模型保持常驻待命。

诚实持久化

字幕 JSONL 追加写、读时按 cue_id 折叠修订;会话清单原子写入。重启后恢复配置、名称、时间与完整字幕,所有运行状态回到 Idle——不伪造仍在采集,也不假装模型还在。

Idle 无模型 Preparing 加载权重 Ready 模型常驻 Running 采集+翻译 Paused 资源释放 prepare 模型就绪 start·新epoch pause resume stop / stop_model(回 Idle) 停止 = 释放采集与播放、恢复原声;模型常驻不退出 —— 启停分离
数据通路时序(一帧的旅程) source chunker translator subtitle speech 100ms × 8 800ms 块 → 模型 SMT2 帧进 / JSON 行出 组句 → JSONL / WS 组句 → 合成 → 排播 字幕组句与 TTS 组句阈值独立:字幕边界不隐式决定播放边界
04 — BOUNDARIES

刻意保留的边界

平台边界

完整音源与播放器仅 macOS;Windows / Linux / 浏览器扩展只保留 trait 适配器边界,Rust Core 无需改动即可移植。

进程边界

音频、模型、TTS 三者都是独立子进程:各自崩溃可恢复(重连 / 重放 / 重启),Core 与 macOS 细节完全解耦。代价是协议必须双侧同步维护。

安全边界

HTTP 仅监听本机且无认证;真实音频接管只在用户显式点击“开始同传”后发生;模拟链路不加载真实模型、不创建音频 Tap。