共享转录引擎服务(hex engine){#shared-transcription-engine-service-hex-engine}¶
状态: 已在 engine-service 上完成测量 spike;计划已就绪。
执行结论¶
- 这能工作吗? 能。 一旦
LocalApi::serve使用poll(2)轮询其监听器,而不是在两次 accept 之间睡眠 10 ms,回环 HTTP(127.0.0.1+ Bearer Token)在/health上增加 0.12 ms,每 100 ms 的/streams音频推送增加 0.28 ms,SSE 部分延迟中位数增加 0.39 ms,整个 WAV 的/transcriptions增加 1.4–3.3 ms。通过 HTTP 完成的流式/streams/:id/finish需要 44.0 ms,而进程内LiveStream::finish_with需要 53.0 ms。 - 它作为后台服务会不会有问题? 有一个真实的 OS 陷阱,且已有经过验证的修复方案: macOS 会将默认后台守护进程(
TASK_BACKGROUND_APPLICATION/taskpolicy -b)限制到 E 核,即使推理线程设置了QOS_CLASS_USER_INTERACTIVE也是如此,这会使安静转录慢 1.7–2.1×(46.5 ms → 76.7–105.5 ms),使有资源争用时的转录慢 最多 15.7×(106 ms → 1,852 ms)。以交互式进程策略运行服务(在launchd中设置ProcessType = Interactive,或从 GUI 父进程继承,并配合NSProcessInfo.beginActivity(.userInitiatedAllowingIdleSystemSleep))可让 Metal + CPU 解码器速度与进程内保持一致(短音频 47.4 ms,长音频 121.6 ms)。 - 这有危险吗? 没有。 引擎 不拥有麦克风、没有 Accessibility/Input Monitoring 监听、没有屏幕录制、没有剪贴板,并且除了
127.0.0.1之外 没有网络套接字。它不会触发任何 TCC 提示,不会在磁盘上存储任何音频或历史记录,并且只通过调用方推送的 PCM/WAV 字节暴露经过身份验证的回环推理。
共享引擎与模型:指南¶
Hex、Seer、Rune Desktop 和 OpenCode Desktop 有两种共享语音识别的方式:要么每个应用将引擎作为库链接,要么由一个引擎进程为所有应用服务。
library in every app one engine process
├── 4 copies of the scheduler ├── 1 scheduler: dictation first, always
├── ~150 MiB Metal + weights per app ├── ~250 MiB once (model file pages are shared either way)
├── apps fight over the GPU, no order ├── one queue across apps
├── 4 builds to update for a fix ├── 1 binary to update
└── each app can download models └── 1 writer for the model folder
库路线无法保证听写始终优先,因为优先级只存在于单个进程内部。一个引擎进程才是正确的形态。 macOS 就是这么运行自己的语音服务的,Ollama 也是这么为本地模型提供服务的。每个应用只链接一个轻量客户端(@kitlangton/hex、Seer 的 hex-client)。
谁拥有什么
- Hex.app 负责分发引擎。
hex engine二进制只存在一份,位于Hex.app内,随其签名和更新。其他应用不会捆绑副本:它们通过 bundle idanomaly.hex找到 Hex,如果没有,则显示“安装 Hex 以使用转录”(Seer 已经会这样回退)。在每个应用中都捆绑副本意味着四份签名、版本交接和“取最新版本”规则;只有当纯 Seer 安装成为真实需求时才需要重新考虑。 - 引擎拥有模型。 只有一个文件夹
~/Library/Application Support/hex/models/,并锁定校验和。只有引擎才能下载、验证、加载或删除模型。应用从不打开模型文件;它们通过请求(GET /models、POST /models/:id/prepare)并发送音频。 - 每个应用拥有自己的麦克风、权限和 UI。 引擎不录制任何内容,因此它从不请求权限。
运行哪个模型: 引擎只保持加载一个模型,即用户在 Hex 中选择的那个。请求中未指定模型时使用该模型。请求中指定另一个模型时,会等待引擎空闲,在当前模型旁加载该模型,然后切换(Linux 和 Windows 已在使用的 ModelSwap)。听写始终使用用户的选择。
让它保持交互式 是两步小而固定的操作,而不是持续存在的风险。macOS 只在进程被标记为后台时才限制它。引擎启动时会清除该标记(setpriority(PRIO_DARWIN_PROCESS, 0, 0)),并持有一个“用户发起”的活动,使 App Nap 不干扰它。在 launchd 下,同样的操作只需一个 plist 键:ProcessType = Interactive。测试会断言引擎不是后台进程,/health 也会报告该状态,因此一旦回归会立即显现。
启动它: 第一个需要引擎的应用会启动 Hex.app/Contents/MacOS/hex engine。引擎会获取 engine.lock,因此第二次启动会立即退出,然后写入 local-api.json。每个应用都会读取该文件以获取端口和 token。Hex.app 在启动时会启动引擎,所以引擎通常已经在运行。没有登录项,也不会出现“已添加后台项目”的通知。
测量 Spike(Mac Studio M5 Max,36 GiB,parakeet-unified-en-0.6b-Q8_0.gguf){#measured-spike-mac-studio-m5-max-36-gib-parakeet-unified-en-06b-q8_0gguf}¶
测试样本:term-hex-3.wav(2.64 s,84.5 KB)和 5 个拼接的词汇片段(13.08 s,418.6 KB)。
| 指标 | 测量值 | 说明 |
|---|---|---|
| 加载模型前的进程内存 | 6.2 MiB phys_footprint(16.0 MiB RSS) |
LocalApi 正在监听,WarmTranscriber 为空 |
| 加载并预热 Parakeet 0.6B 后的进程内存 | 249.3–252.7 MiB phys_footprint(199.3–203.0 MiB RSS) |
731 MB GGUF 中有 697 MiB 是通过 MTLBuffer(bytesNoCopy,脏字节为 0 KiB)包装的干净只读 mmap;脏占用为 117.1 MiB Metal 驱动/管线 + 31.4 MiB 融合 BN 权重 + 50 MiB malloc 缓存 |
进程内模型卸载(*model = WarmTranscriber::default()) |
0.84 ms;降至 213.3–222.4 MiB phys_footprint(153.7–167.3 MiB RSS) |
取消映射 697 MiB GGUF,并释放 31 MiB BN 缓冲区;ggml-metal 的进程级全局 g_device(117 MiB)会驻留到进程退出 |
| 冷加载(新构建首次运行,Metal 着色器缓存为空) | 3,665–4,483 ms(3,322–3,453 ms Metal 初始化 + 76–501 ms 预热 + 267–528 ms 流探测) |
MTLCompilerService 将 ggml-metal 内核编译一次,放入 Darwin 用户着色器缓存 |
| 冷进程启动(OS Metal 着色器缓存为热) | 386.5–585.0 ms(178 ms Metal 加载 + 89 ms 离线预热 + 317 ms 流探测) |
新的子进程 hex engine 从 fork/exec 到 HTTP 就绪 |
| 进程内热重载(空闲卸载后) | 总计 301–308 ms(19–25 ms load_from + 33–39 ms 离线预热 + 247 ms 流探测) |
52–64 ms 即可用于离线推理;ggml-metal 管线在 g_device 中保持已编译状态 |
通过环回 HTTP 的 GET /health |
中位数 122.3 µs(p95 145.0 µs) | 在将 LocalApi::serve 中的 thread::sleep(10ms) 替换为 libc::poll 之前为 12,522.5 µs (12.5 ms) |
POST /transcriptions — 2.64 秒 WAV |
进程内 46.46 ms 对比 HTTP 47.83 ms(+1.37 ms) | 包含 TCP 连接、Bearer 认证、WAV 解析、JSON 应答 |
POST /transcriptions — 13.08 秒 WAV |
进程内 106.08 ms 对比 HTTP 109.42 ms(+3.34 ms) | 包含 418.6 KB 环回上传 + WAV 解码 |
/streams — 13.08 秒匀速推送(100 ms / 6.4 KB 分块) |
创建 0.31 ms;音频推送中位数 282.9 µs(p95 357.8 µs);SSE 部分结果中位数 387.5 µs(p95 541.4 µs);/finish 中位数 44.02 ms(对比进程内 LiveStream 的 53.01 ms) |
暂停-裁剪窗口在语音期间提交,因此 /finish 仅解码尾部 |
QoS:交互式对比 taskpolicy -b(安静场景) |
短音频:46.5 ms 对比 76.7–105.5 ms(慢 1.7–2.2 倍);长音频:106.1 ms 对比 200.6–204.9 ms(慢 1.9 倍) | 即使 pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE) 成功,TASK_BACKGROUND_APPLICATION 仍会将线程限制在 E 核上 |
QoS:交互式对比 taskpolicy -b(12 线程 CPU 负载) |
长音频:中位数 111.6–126.1 ms(最大 133.1 ms)对比中位数 272.7–808.5 ms(最大 1,851.9 ms,最高慢 15.7 倍) | 证明后台任务策略无法满足交互式听写的需求 |
SMAppService.agent(ProcessType = Interactive、Aqua) |
短音频:中位数 47.41 ms(p95 47.84 ms);长音频:中位数 121.58 ms(PID 72058,status = Enabled) |
与前台进程内速度相差在 1 ms 以内;可干净地注销(status = NotRegistered) |
1. macOS 上的服务生命周期¶
| 选项 | 优点 | 缺点 |
|---|---|---|
位于 Hex.app/Contents/Library/LaunchAgents/ 的 SMAppService.agent(plistName:) |
由 launchd 监督崩溃恢复(KeepAlive.SuccessfulExit = false),并应用 ProcessType = Interactive + LimitLoadToSessionType = Aqua。 |
通过 sfltool dumpbtm 验证:macOS 会发布一条 “Background Items Added”(已添加后台项目) 通知(Disposition: [enabled, allowed, notified]),并在系统设置中新增一个独立开关,关闭后会破坏转录功能(RequiresApproval)。此外还要求安装 Hex.app,在仅 Seer 或仅 Rune 的安装环境下会失败。 |
SMAppService.mainApp 登录项 |
Hex.app 已将其用于“登录时启动”。 |
会启动完整的 Hex.app 菜单栏应用,而不是为仅 Seer 用户提供无头引擎。 |
按需启动的用户会话守护进程(hex engine),通过 engine.lock + local-api.json 协调(推荐) |
零条 BTM “Background Items Added”(已添加后台项目)通知;无论用户安装了 Hex.app、仅 Seer、仅 OpenCode Desktop,还是全部四个应用,行为都完全一致;由 GUI 应用启动时会继承前台交互式任务策略。 |
发生连接失败时,由客户端 SDK/宿主辅助程序重新拉起守护进程,而不是依赖 launchd 的 KeepAlive。 |
建议: 使用通过 ~/Library/Application Support/hex/engine.lock 和 local-api.json 协调的按需分离式用户守护进程(hex engine),同时 Hex.app 保留其现有的 SMAppService.mainApp 登录项。
client.connectOrStartEngine()
├── read ~/Library/Application Support/hex/local-api.json -> GET /health (150 ms)
│ └── 200 OK & compatible apiVersion -> return endpoint
└── missing, stale, or refused
├── spawn `<bundled-or-installed-hex> engine` detached (setsid, stdio -> /dev/null)
├── engine acquires flock(engine.lock, LOCK_EX | LOCK_NB) (losers exit 0)
├── engine binds 127.0.0.1:0, writes 0600 local-api.json atomically, serves
└── client polls local-api.json until healthy (budget: 2 s)
- 仅 Seer / 非 Hex 安装: 目前引擎仅随
Hex.app一起发布(见上文指南);其他应用从该位置启动它,若未安装则回退到 “Install Hex”。如果确实需要仅 Seer 的安装形态,同样的锁与发现机制可让某个应用自带hex engine:哪个应用先启动,谁就成为用户会话中的唯一引擎,之后Hex.app启动时会共享该引擎。 RENAME_SWAP自更新: 在 macOS 上,renamex_np(RENAME_SWAP)会替换Hex.app的目录项,同时正在运行的引擎所打开的可执行文件 inode 以及mmap的 GGUF 文件描述符仍然有效。local-api.json携带engineVersion(crate 版本 + git SHA);当更新后的Hex.app启动并发现/health中的engineVersion较旧时,它会发送POST /control {"command":"shutdown-when-idle"}(该命令会排空所有正在进行的/streams或/transcriptions任务并以 0 退出),然后启动其自带的更新版hex engine。
2. 传输层与身份验证¶
建议: 继续使用经过认证的环回 HTTP(127.0.0.1:<ephemeral-port>),配合 0600 权限的 local-api.json 发现机制与随机 256 位 Bearer token,并发布 src/local_api.rs 中那 12 行 libc::poll 接受连接修复(此外为 /streams/:id/audio 的连接复用启用 HTTP/1.1 keep-alive)。
- 在
listener.as_raw_fd()上使用libc::poll后,环回 HTTP 每次控制调用耗时 0.12 ms,每 100 ms 音频块耗时 0.28 ms,SSE 部分结果耗时 0.39 ms——比人类感知阈值(约 15 ms)低 40 倍以上。 - 使用 Unix 域套接字每次调用可节省约 40 µs,但会破坏 TypeScript 客户端(
@kitlangton/hex、Seer、OpenCode Desktop)中标准的fetch/EventSource兼容性。
3. 进程优先级、QoS 与 Metal 访问¶
建议: 在 hex engine 内部强制执行交互式任务 + 线程 QoS,并持有 App Nap 活动断言:
- 任务策略: 绝不在
TASK_BACKGROUND_APPLICATION下运行hex engine。启动时,hex engine会调用setpriority(PRIO_DARWIN_PROCESS, 0, 0)来清除继承来的PRIO_DARWIN_BG,并在进程整个生命周期内持有NSProcessInfo.processInfo.beginActivity(options: [.userInitiatedAllowingIdleSystemSleep], reason: "Hex speech transcription engine")token。这样可防止 App Nap 对定时器/CPU 进行节流,同时仍允许 Mac 在空闲时休眠。 - 线程 QoS:
TranscriptionEngine工作线程调用prioritize_inference_thread()(即pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0)),使单线程的 Parakeet CPU token 解码器保持在 P 核上运行。 - 用户会话中的 Metal: 由于
hex engine由 GUI 客户端在用户的Aqua登录会话内启动(或在LimitLoadToSessionType = Aqua下启动),MTLCreateSystemDefaultDevice()拥有完整的硬件 GPU 访问权限。在活跃推理期间(其本身已让位于听写任务),会议/后台音频块会继续将线程 QoS 降低到QOS_CLASS_UTILITY,并在随后立即恢复为QOS_CLASS_USER_INTERACTIVE。
4. 内存与模型驻留¶
建议: 保持引擎进程驻留(6.2 MiB 基础占用;首次 Metal 初始化后约 ~222 MiB),空闲 10 分钟后卸载 WarmTranscriber(*model = WarmTranscriber::default()),并在听写按键按下时预热(POST /warm)。
- 原因: Parakeet 0.6B Q8_0(
731 MBGGUF)在加载时仅使用 249–253 MiB 脏phys_footprint,因为transcribe.cpp将 697 MiB 干净的mmap文件页直接包装在MTLBuffer(bytesNoCopy)中。卸载WarmTranscriber会在 0.84 ms 内取消映射 697 MiB 文件,并释放ggml-metal的 3 分钟MTLResidencySet以及 31 MiB 的融合 BatchNorm 缓冲区,同时保持ggml-metal的编译管线缓存在进程内保持热状态。 - 热重载延迟: 在进程内重新加载
Parakeet需要 19–25 ms(load_from)+ 33–39 ms(离线预热)= 52–64 ms(或使用 8 秒流式探测时为 301 ms),而冷启动一个新进程需要 387–585 ms。 - 空闲后零延迟听写: 当用户在
Hex.app中按下听写快捷键时,Hex.app会在按键按下(t = 0 ms)时立即触发POST /warm(或打开其推测性的POST /streams会话)。由于Hex.app的有意按住阈值为 300 ms 加上语音时长和 200 ms 的松开后尾音,未加载的模型会在用户说完第一个词之前(52–301 ms)完成重新加载。
5. 崩溃、挂起与重启行为¶
建议: 无状态快速失败恢复,并在 Hex.app 中提供保留音频回退。
- 进行中的
/streams和/transcriptions请求: 如果hex engine崩溃或挂起超过客户端的截止时间,套接字将关闭(EOF/ECONNRESET)。外部 SDK 客户端(Seer、OpenCode)会在该流上收到类型化的EngineDisconnected错误,调用connectOrStartEngine(),并在下一次话语时于 ~390–585 ms 内重新连接。 Hex.app听写韧性:Hex.app会在自己的内存中保留权威的 16 kHz PCM 采集缓冲区(dictation_audio),直到粘贴完成。如果到hex engine的推测性/streams会话在采集过程中断开或在/finish处超时,Hex.app会重新拉起hex engine,并通过POST /transcriptions提交保留的完整片段(这正是TranscriptionEngine::finish_with目前执行的从推测性流到完整片段的回退)。任何听写语音都不会因引擎崩溃而丢失。- 崩溃循环节流: 客户端在进程内跟踪引擎生成时间戳,并将重启限制为每 30 秒 3 次尝试(采用
100 ms → 1 s → 5 s退避),之后才向外呈现EngineUnavailable。
6. 跨进程的优先级与抢占(听写优先)¶
建议: 为请求标记 priority 层级(dictation vs interactiveStream vs background),并在 hex engine 内部保留现有的 dictation_busy 原子抢占门控。
POST /streams { priority: "dictation" | "stream" }
├── priority: "dictation" (Hex.app hotkey / Voice Action)
│ ├── increments dictation_busy in engine -> immediately closes any active YieldingStream (Seer/OpenCode)
│ ├── claims exclusive LiveStream on TranscriptionEngine
│ └── any competing /streams push buffers in its 30 s backlog and catches up via offline window after dictation finishes
└── POST /transcriptions { priority: "dictation" | "normal" | "background" }
└── engine queue orders: dictation > stream window > normal batch > background meeting chunk
Hex.app听写的 IPC 延迟预算:- 快捷键按下 →
POST /streams(priority: "dictation"):0.31 ms(在 300 ms 按住阈值期间执行;0 ms 计入松开后延迟)。 - 语音期间 → 每 100 ms 发送
POST /streams/:id/audio:每个 6.4 KB 块在音频线程上耗时 0.28 ms(0 ms 计入松开后延迟)。 - 快捷键松开(+200 ms 尾音)→ 使用最终尾音样本发送
POST /streams/:id/finish:总计 44.0 ms(其中 < 0.5 ms 是回环 HTTP 帧封装/序列化)。
7. API 版本管理与客户端偏差¶
建议: 采用单一可叠加的 API_VERSION = "2" 契约,并通过 GET /health 和 GET /capabilities 进行能力发现。
local-api.json和GET /health报告apiVersion: "2"、engineVersion: "2.3.1+sha"、pid以及capabilities: ["transcriptions", "streams", "warm", "priority-dictation"]。- 可叠加演进: JSON 请求/响应中的新字段会干净地默认化(
#[serde(default)])。不兼容的破坏性变更会递增apiVersion。 - 版本替换规则: 当
Hex.app或更新的 SDK 客户端启动时,如果发现正在运行的引擎其engineVersion比自己捆绑的引擎二进制文件旧,它会请求优雅交接(POST /control {"command":"shutdown-when-idle"}),以确保最新安装的引擎二进制文件始终服务会话,同时不破坏旧的 v2 客户端。
8. 安全与隐私(“它有危险吗?”)¶
建议: 保持引擎无特权、仅回环、无存储。
- 零 TCC 暴露面:
hex engine从不链接或打开 CoreAudio 输入、CGEventTap、AXUIElement、ScreenCaptureKit或NSPasteboard。它无法录制用户或读取按键/屏幕;macOS 永远不会代表它弹出权限提示。 - 回环 +
0600令牌认证: 严格绑定到127.0.0.1:0(绝不使用0.0.0.0或::)。local-api.json以mode(0o600)创建在0700权限的~/Library/Application Support/hex/目录内。每条路由都要求Authorization: Bearer <token>(在读取主体之前检查),并且每个/streams/:id会话都要求其每流X-Hex-Stream-Token。 - 硬资源上限(已在
local_api.rs/streams.rs中执行): 4 个 HTTP 工作线程、连接队列上限 16、16 KiB 头部上限、2 秒头部截止时间、64 MiB WAV 上传上限、最多 1 个并发流上传、256 KiB 流音频块上限、30 秒流积压上限以及 15 秒流租约超时。 - 不保留用户数据:
hex engine不写入任何转写文本、history.json或 WAV 文件;Hex.app继续在自己的进程中拥有history.json和会议产物。
9. Hex.app 如何与它通信及增量 PR 计划¶
Hex.app 将所有 macOS 专门的桌面职责保留在进程内:dictation_audio(CoreAudio 麦克风 + 预滚动)、suppression(事件监听 + 快捷键状态机)、dictation_indicator(Metal HUD)、audio/recording_environment(静音/媒体暂停/休眠)、dictation_processor(修正、OpenCode 重写、转换)、paste、history、recognition(Moonshine 语音命令)、meeting_capture 以及 Rune 设置窗口。只有 TranscriptionEngine(Parakeet/Cohere/Whisper GGUF + Apple Speech)移到了 EngineClient 接缝之后,通过 local-api.json 与 hex engine 通信:
- PR 1 —
LocalApi::servepoll(2)accept + HTTP keep-alive: 在src/local_api.rs中落地 12 行libc::poll修复(将回环延迟从 12.5 ms 降至 0.12 ms)以及POST /warm。 - PR 2 — 独立的
hex engine命令及锁/发现生命周期: 将hex service/hex engine提升为启动并共享TranscriptionEngine,支持交互式 QoS、App Nap 断言、10 分钟空闲模型卸载以及shutdown-when-idle。 - PR 3 — 优先听写流和会议块路由: 在
/streams上增加priority: "dictation"抢占,在/transcriptions上增加后台优先级,以便外部客户端像当前的进程内YieldingStream一样,让位于Hex.app热键听写。 - PR 4 —
Hex.app作为引擎客户端 +@kitlangton/hex自动启动: 将Hex.app的听写和会议工作器切换到connectOrStartEngine()(在Hex.app中保留 PCM 片段,以实现崩溃安全的整片段重试),并更新@kitlangton/hex,使其在local-api.json不存在时自动启动hex engine。
10. Linux 和 Windows 对应实现¶
- Linux: 现有的
hex.service每个用户的 systemd 单元(Type=notify、PartOf=graphical-session.target、Restart=on-failure)已经在每个用户和数据根目录下运行一个单一的长生命周期进程(docs/plans/single-process.md)。在 Linux 上的LocalApi中共享TranscriptionEngine,会在127.0.0.1上暴露完全相同的local-api.json+/streams+/transcriptions契约;非 systemd 桌面环境则使用相同的engine.lock+ 分离式hex engine启动。 - Windows:
WindowsDesktopHost(或一个无头hex.exe engine,使用CREATE_NO_WINDOW | DETACHED_PROCESS启动,并通过命名互斥体 + 仅所有者 DACL 的%APPDATA%\hex\local-api.json进行协调)提供相同的回环 HTTP API,并使用SetProcessInformation(..., ProcessPowerThrottling)禁用 EcoQoS 节流,以便 Vulkan/CPU 推理保持全速运行。
本次 Spike 未证明的内容¶
- 多小时的 macOS 内存压力 / 睡眠-唤醒循环: 我们测量了显式的进程内卸载(
0.84 ms,52–301 ms 重新加载)以及taskpolicy -bvsInteractive,但没有在 macOS 显示器睡眠/休眠(darkwake)期间进行 24 小时浸泡测试。 - 同时 4 个应用争用追踪: 我们验证了通过回环 HTTP 的
/streams+/transcriptions以及 12 线程 CPU 争用,但跨两个独立 OS 进程的priority: "dictation"抢占路由(Hex.app抢占活动的Seer/streams会话)计划在 PR 3 中实现(目前YieldingStream抢占由针对进程内TranscriptionEngine的单元测试覆盖)。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 hex19/hex