作者详细记录用全局快捷键→音频录制→VAD→STT→屏幕截图→本地LLM→流式UI→TTS,构建响应即时且可打断的桌面语音助手全过程。
按下按钮,说话,让助手查看我的屏幕,然后获得答案——不需要打开六个标签页,也不需要重新解释眼前已经展示过的内容。
在我脑海中,架构是这样的:
microphone → local AI magic → useful answer
在代码里,实际是这样:
global shortcut
→ audio recorder
→ voice activity detection
→ speech-to-text model
→ screen capture
→ image compression
→ local LLM server
→ streaming UI
→ text-to-speech
→ interruptible text-to-speech playback
模型是最简单的部分。
让所有模型像一个快速、沉稳的助手那样协同运作,才是真正有趣的地方。
这个项目是我独自备考时开始做的。我不断在工作和聊天机器人标签页之间来回切换,复制上下文,提问,再把答案复制回来,慢慢地失去了继续下去的意志力。
我当时还在为听写软件付费,因为说话比打字快。听写功能能用,但它只停留在文本层面。它能听见我,却无法帮助我。
所以我开始构建我真正想要的东西:一个桌面上的语音层。
SpeakoFlow 起源于 Handy 的一个分支,Handy 为我提供了一个扎实的本地听写基础。我没有重新发明那部分,这一点我必须明确说明。我构建的是助手、屏幕视觉、口语回答、翻译、记忆,以及将这些组件编排成一个整体体验的编排层。
以下是让我最意外的问题。
我的第一个思维模型是完全串行的:
加载转录模型。
转录音频。
启动语音合成。
这个流水线在技术层面没有任何问题。
只是感觉糟透了。

全本地流水线仍然可能感觉慢,当每个阶段都是串行执行时。关键路径比在设备上运行的组件数量更重要。
最大的延迟改进不是来自切换模型,而是来自拒绝等待。
用户说话需要花几秒钟。那段时间是免费的延迟预算。
当助手开始录音时,SpeakoFlow 可以并行启动有用的工作:
// Simplified version of the real flow
fn recording_started() {
initiate_transcription_model_load();
preload_vad();
spawn(prewarm_local_llm());
spawn(capture_screen_if_armed());
stop_previous_spoken_answer();
show_listening_state();
}
当用户说"你能解释一下这个终端里的错误吗?"时,应用已经在加载本地模型并准备视觉上下文了。
等到语音转文字完成时,大部分冷启动工作可能已经消失了。
"Hey Flow"预热路径更窄。它只在以下条件同时满足时运行:Flow 已启用、流式转录模型产生了实时文本、激活词领先于已提交的转录文本,且选定的助手提供商是内置引擎。批量转录模型不会发出实时文本,云服务提供商也没有本地模型需要加载。
这样就避免了在普通听写时唤醒一个多 GB 的 LLM,同时仍然将启动时间与真正的助手请求重叠。
经验很简单:
在语音界面中,用户的说话时间是延迟预算的一部分。
我避免引用一个通用的延迟数字,因为冷启动因模型、硬件和加速器而异。更有用的衡量标准是哪项工作仍然阻塞着第一个可见的 token。
这不会让模型推理变得免费。它只是消除了阶段之间可避免的空闲间隙。
普通按钮通常只发出一个事件。全局快捷键可能发出重复的按下事件、延迟的释放事件,或者根本没有释放事件。
释放事件可能迟到。当转录仍在运行时用户可能再次按下。免手动的录音可能比启动它的按键存活得更久。一个过期的定时器可能醒来并试图停止一个完全不同的录音。

我最终通过一个协调器来路由录音,它有明确的状态:
Idle → Recording → Processing → Idle
协调器拥有 start、stop、cancel、commit 和免手动转换的所有权。它拒绝重复触发,并为安全计时器打上标签,这样一个旧的计时器就无法停止一个更新的录音。
串行化这些转换是关键。每个生命周期变化都经过一个协调器,所以重复的按键事件无法与异步转录和粘贴流水线竞争。
语音软件几乎没有模糊的空间。如果应用启动了两次、忽略了一句话、或者在用户停止后继续录音,信任会立即消失。
屏幕视觉听起来很简单:
let screenshot = capture_screen();
send_to_model(screenshot);
在截图碰到严格的 API 网关、小的本地上下文窗口或多显示器设置之前,它运行得很漂亮。
SpeakoFlow 捕获鼠标光标所在位置的显示器,以主显示器作为后备。光标通常是最好的线索,表明用户实际在哪里工作。
然后图像通过特定提供商的压缩阶梯。
当前代码有不同的配置文件针对:
严格的网关如 Azure
本地 llama.cpp 视觉模型
具有更大 payload 限制的云模型
每个配置文件尝试图像尺寸和 JPEG 质量的组合,直到 base64 payload 符合其目标。
for (max_dimension, quality) in profile.ladder {
let jpeg = resize_and_encode(&image, max_dimension, quality);
if base64_size(&jpeg) <= profile.target_bytes {
return as_data_url(jpeg);
}
}
如果没有一级符合目标,编码器保留最小的尝试而不是让捕获失败。
目前粗略的目标范围从严格网关的约 48 KB 到本地视觉的 200 KB,再到更宽松的云提供商的 384 KB。
对于本地模型,这不只关乎传输速度。截图消耗视觉 token。做得太大就会把对话挤出上下文窗口。做得太小模型就无法读取你希望它帮忙解释的错误信息。
这使得屏幕捕获成为一个约束编码问题,而不是简单的截图调用。
SpeakoFlow 将权限与时机分开。它们不是同一个设置。
关闭:屏幕捕获被禁用。
手动:用户为该会话明确启用屏幕共享,或者为特定回合附加图像。
助手决定:请求开始时没有图像,模型收到一个 capture_screen 工具。它只在问题真正依赖于可见上下文时才能调用该工具,并且每次消息最多调用一次。
立即:在录音开始时捕获,保留用户开始提问时看到的内容。
发送时:在转录完成后捕获,使用发送请求时可见的内容。
键入的消息在发送时捕获,因为它们没有录音开始事件。在助手决定模式下,捕获发生在模型调用工具时,所以立即和发送时设置不控制该路径。
立即捕获在用户说话时在后台线程中运行。该帧带有一个生成 token。如果用户取消、开始另一个回合或更改屏幕权限,token 变得无效,旧的帧无法附加到后续请求。
助手决定路径在工具成功时向对话添加一个可见的截图标记和紧凑缩略图。即使模型选择了何时查看,这也保留了审计跟踪。
取消不是按钮。这是每个后台任务都必须理解的规则。
在对话历史中保留全分辨率截图听起来很方便,直到我考虑到这意味着什么。
之后每个请求都可能重新发送相同的图像。上下文使用量会增加。历史文件会变重。应用会保留比所需更多的视觉数据。
所以完整图像只属于一次模型回合。
SpeakoFlow 为可见的聊天历史创建一个较小的缩略图。用户仍然可以看到共享了什么,但原始帧不会反复发送回模型。
这在保留可见审计跟踪的同时,防止了重复的图像 payload 消耗上下文并增加持久化的对话历史。
内置助手将 llama.cpp 作为本地回环服务运行。
应用必须照料它:
找到或下载兼容的引擎。
为操作系统选择正确的构建。
在 127.0.0.1 上启动。
加载选定的 GGUF 模型。
在需要时附加视觉投影仪。
等待健康检查。
在活动请求期间保持存活。
在配置的空闲期后卸载。
确保应用退出时它也退出。
两个快速请求也不能被允许启动两个服务器副本。启动是串行化的,模型切换等待先前进程释放端口。
一个微小的标志带来了出乎意料的大改进:
--parallel 1
llama-server 通常支持多个生成槽。这对共享服务器来说是合理的。SpeakoFlow 是一个单用户桌面应用。
多个槽会将可用上下文分配给并发请求。截图可能已经消耗了小模型上下文窗口的有意义部分,所以分割剩余部分可能导致早期截断或 KV-cache 分配失败。
一个槽给活动的桌面对话完整的配置上下文。罕见的重叠请求排队而不是竞争碎片化的缓存空间。
上下文失败来自为多用户工作负载优化的服务器默认值,而不是模型本身。

本地引擎暴露一个 OpenAI 兼容端点。同一个 Rust 客户端可以对接:
内置的 llama.cpp 引擎
OpenAI 兼容的云服务
其他只需小幅认证适配的提供商
响应以 SSE 事件形式到达。Rust 后端在生成进行中时将文本转发到 React 助手面板,但将增量合并为最多每约 40 ms 发出一次。每次发出成为面板 WebView 中的一个 evaluate_script 调用,而上游路径每次调用都会泄漏内存,所以批处理保留了流式效果而不会每个 token 发出一个 WebView 调用。当回合结束时,权威的对话快照替换临时流式文本。
助手也可以运行一个小而有界的工具循环。根据用户的设置,模型可以决定搜索网页、获取当前日期或捕获屏幕。
轮次上限防止模型反复调用同一工具而不产生最终回复。
语音合成位于生成之后,但它有两个独立的职责:规范化语音文本并使播放可取消。

显示的答案可能包含 Markdown、代码块、链接和表情符号。SpeakoFlow 在面板中保留原始回复,同时将一个清理后的副本发送到语音引擎。
本地语音通过 WebGPU 在助手面板中运行 Kokoro(如果可用的话),对于不支持或 GPU 路径失败的情况则回退到 WASM。远程 OpenAI 兼容、ElevenLabs 和 Azure 语音是可选的。
中断是更难的问题。
如果用户开始一个新问题,之前的答案必须立即停止。SpeakoFlow 使用单调递增的播放时代:
Reply A 以 epoch 12 开始
用户开始新的录音
当前 epoch 变为 13
Reply A 发现 12 已过时并停止
同样的机制防止一个缓慢的旧语音请求在对话已经继续之后突然播放。
目前语音在完整文本答案就绪后开始。我考虑过在 token 到达时说出部分句子,但完整的回复使清理、取消和发音更可预测。
句子级流式传输是未来可能的优化,但当前设计优先考虑可预测的清理和取消。
语音转文字始终在用户的机器上运行。其他阶段都是可选的:
使用用户自己密钥的云模型
本地 Kokoro 语音合成
配置的远程语音提供商
这在真实硬件上很重要。
笔记本电脑用户可能想要私密的本地转录但使用云 LLM 来保护电池续航。带有 GPU 的台式机用户可能想要整个流水线离线。
本地优先在每个边界都明确时效果更好:转录位置、助手提供商、视觉权限、记忆存储和语音引擎都可以独立配置。
实现按生命周期职责而不是模型供应商来划分:
保持这些边界分离使故障更容易定位。响应缓慢、截图过时、重复热键事件和延迟的 TTS 播放各有不同的生命周期所有者。
如果明天我再构建一个语音界面,我会保留这些规则:
把冷启动隐藏在用户已经在做的操作中。说话时间是可用时间。
让录音成为一个状态机。热键不是普通按钮。
给每个后台任务一个身份。旧工作必须能够变得无效。
把视觉上下文当作预算。分辨率、payload 大小和视觉 token 是竞争关系。
发送完整图像一次。为历史保留小缩略图。
让语音输出易于中断。助手永远不应该与用户抢话权。
优化循环,而不是基准测试。更快的模型无法拯救串行流水线。
该项目是开源的,所以上面的模块和生命周期边界可以直接检查:
在 GitHub 上查看 SpeakoFlow
我对其他开发者如何处理两个选择感到好奇:
你会从部分句子开始语音合成,还是等待完整答案?
对于屏幕视觉,你会在用户开始说话时捕获还是在他们说完时捕获?
如果有兴趣,我可以写一篇更深入的后续文章关于截图压缩阶梯或管理 llama.cpp 作为桌面 Sidecar。
P.S. 我根据自己构建 SpeakoFlow 的经验写了这篇文章,AI 帮助我塑造和润色了文章。英语不是我的第一语言,所以它对组织思路和提高清晰度很有用。技术工作、经验教训和观点都是我自己的,我已对照源代码核对了细节。