Typeless 的交互让我眼前一亮——按一下键开始录音,再按一下停止,文字直接出现在光标位置。但 $12/月,$144/年。核心链路就是快捷键、录音、转写、粘贴,我决定自己做一个。

我以为一个周末能搞定。实际上,从第一行代码到能日常使用,中间踩了五个大坑,每个坑都是"代码本身没问题,是我对 macOS 底层机制理解不够"。


先看全貌

整个应用常驻菜单栏,核心是一个状态机:

idle → [快捷键按下] → recording → [快捷键再按] → processing → idle
                         ↓
                    [双击取消]
                         ↓
                       idle

用户按一下快捷键,开始录音,屏幕底部弹出进度浮窗显示音量。再按一下,录音文件发给 OpenAI Whisper API 转写,文字自动粘贴到光标位置。如果当前没有输入框,弹浮窗显示结果。

技术栈:

层级 技术 选型理由
应用框架 Swift + SwiftUI + AppKit 原生体验,MenuBarExtra 常驻菜单栏
音频录制 AVAudioRecorder(M4A, 44.1kHz 单声道) 系统自动处理格式协商,最稳定
语音转写 MacPaw/OpenAI Swift SDK 社区最活跃的 OpenAI Swift SDK,省去 multipart 编码
全局快捷键 CGEvent tap 唯一支持 modifier-only 键的方案(如单按右 Alt)
文字注入 剪贴板 + 模拟 Cmd+V → 浮窗降级 兼容性最好的组合
进度浮窗 纯 AppKit(NSPanel + NSView + CALayer) SwiftUI 在这个场景下会崩溃,后面细讲

这张表里每一个"选型理由",背后都有一段弯路。


快捷键:CGEvent tap 和 modifier-only 的坑

我以为注册全局快捷键就是一行代码的事。

第一个问题是冲突。设了 Cmd+Shift+R,被截图软件抢了。换 Ctrl+Space,被输入法占了。换成三键组合,自己按着都嫌烦。语音输入是高频操作,按三个键才能启动,没人愿意用。

最后决定用单个修饰键——右 Option(Alt)。一个键,不和任何快捷键冲突。但 macOS 的常规快捷键 API 不支持"只按一个修饰键"作为触发条件。修饰键在系统看来是"修饰符",不是"按键"。

解法是 CGEvent tap——比普通快捷键 API 低一层,直接拦截系统事件流。它能看到每一个键盘事件,包括单独按下修饰键。但也意味着必须处理所有边界情况:修饰键按下但随后按了其他键(用户在按组合键,不是在触发录音)、快速双击(取消录音)、左右修饰键区分(我的 Windows 键盘接 Mac,右 Ctrl 和右 Alt 在系统层面是同一个键码)。

后来加了快捷键自定义录制。用户点"录制"按钮,按下想要的键,保存。听起来简单,但全局快捷键监听会拦截按键事件,导致录制组件收不到。解法:录制时临时 pause 全局监听,录完 resume。

这种"两个子系统互相打架"的模式,后面还会反复出现。


权限:不是代码的 bug,是认知的 bug

macOS 要求应用获得"辅助功能"权限才能监听全局快捷键。授权一次就好——我是这么以为的。

每次改完代码重新编译运行,快捷键就失灵了。系统设置里权限还显示"已开启",但实际不生效。我反复改快捷键逻辑、换注册方案、加日志追踪,查了好几个小时。

最后发现:每次编译生成的是一个"新的"二进制文件,code signature 变了,系统认为是不同的应用,旧的授权自然失效。这不是代码的 bug,是我对 macOS 安全模型理解不够。

解法写在 README 里了:在 Xcode 里勾选 “Automatically manage signing”,选择 Personal Team。这样每次编译签名一致,权限不会失效。不需要付费 Apple Developer 账号,免费 Apple ID 就行。

麦克风权限也有类似的认知盲区。从 Xcode 运行应用时,需要给 Xcode 本身授权麦克风,而不是给编译出来的应用。

这两个权限问题加起来浪费了将近一天。教训很明确:遇到"代码明明没问题但就是不 work"的时候,先怀疑自己对系统机制的理解,再怀疑代码。


录音:从 AVAudioEngine 到 AVAudioRecorder

录音应该是整个流程里最基础的一步。我在这上面花的时间最多。

第一版用 AVAudioEngine——苹果"现代"的音频框架,支持实时处理、格式转换、多节点串联。文档写得很美好。现实是各种报错:tap 格式不匹配、设备初始化失败、引擎启动异常。试了至少六七种配置组合,参考了好几个开源项目。

中间有一次特别无语。所有方案都试遍了还是报错。最后发现是 USB 麦克风松了,系统根本没检测到输入设备。不是代码的问题,是物理世界的问题。

最终方案是 AVAudioRecorder——苹果"老"的录音接口。一个类,指定格式(M4A, AAC, 44.1kHz mono),调 record(),调 stop(),拿到文件。所有格式协商交给系统处理。

AVAudioEngine 能做的事远比 AVAudioRecorder 多。但我不需要实时音频处理,不需要多节点串联,只需要"录一段音频,存成文件"。最笨的方案恰好是最匹配需求的方案。

录音格式选 M4A 而不是 WAV,因为 AVAudioRecorder 原生支持,Whisper API 也直接接受,不需要任何格式转换。文件体积小了 10 倍,上传更快。


进度浮窗:SwiftUI 在这里崩溃了

Typeless 有一个关键设计:录音时屏幕底部出现小浮窗,显示状态和音量波动。没有它,用户说了一分钟话,不知道到底有没有在录。

第一版用 SwiftUI 的 ObservableObject 驱动浮窗 UI。录音状态变化时更新 @Published 属性,SwiftUI 自动刷新视图。标准做法。

但在这个场景下,ObservableObject 的更新和 CGEvent tap 的回调在不同的 actor 上——一个在 MainActor,一个在系统事件线程。Swift 的 actor isolation 检查直接让应用崩溃。

这不是写法的问题。SwiftUI 的并发模型和 CGEvent tap 的底层回调机制有根本性的冲突。我试了各种 @MainActor 标注和 DispatchQueue.main.async 包装,要么崩溃,要么音量更新延迟到肉眼可见。

最后放弃 SwiftUI,用纯 AppKit 重写:NSPanel 作为窗口(不抢焦点)、NSView 手动布局、CALayer 画音量脉冲动画。代码量多了一倍,但稳定了。

“新"不一定是"好”。 SwiftUI 在大部分 UI 场景下很好用。但当你需要和底层系统机制深度交互时,它的抽象层反而成了障碍。选技术方案的标准是"能不能在这个具体场景下稳定工作",不是"是不是最新的"。


文字插入:一个看似简单的问题的排列组合

转写完成,拿到文字,粘贴到光标位置。听起来是最后一步,应该最简单。

第一版用 macOS Accessibility API 直接设置输入框的 value。调用返回成功,但有的应用(比如 Terminal)实际上什么都没发生——AXUIElement 报告"设置成功",内容没变。这是 Accessibility API 的已知行为:部分应用的 AXUIElement 实现不完整。

换成剪贴板方案:把文字写入剪贴板,模拟 Cmd+V 粘贴。兼容性好了很多,但引入新问题——粘贴会覆盖用户原来的剪贴板内容。解法:粘贴前保存剪贴板,粘贴后恢复。

还有一个问题:如果用户停止录音时光标不在输入框里(比如在浏览器的空白区域),粘贴操作虽然执行了,但没有目标接收。用户以为转写失败了。

最终设计了一个 fallback 链:

  1. 录音停止的瞬间,用 OutputTargetSnapshot 捕获当前聚焦的 UI 元素和应用 PID
  2. 转写完成后,检查快照里的元素是否有可编辑区域(hasTextInput
  3. 有 → 剪贴板 + Cmd+V 粘贴
  4. 没有 → 弹出浮窗,显示文字 + Copy 按钮

为什么在停止录音时就捕获快照,而不是转写完成后?因为转写需要几秒,用户可能在等待期间切换了窗口。如果等转写完再检测焦点,可能粘贴到错误的位置。

在自己的设置界面测试时还发现了一个 bug:文字出现了两遍。原因是设置界面有自己的 SwiftUI 文本绑定,同时剪贴板粘贴也触发了一次更新,两条路径同时生效。解法是在设置界面禁用全局插入逻辑。

每个问题单独看都不难。但光标位置、应用类型、输入框状态的排列组合非常多,每种都要单独验证。


设计决策回顾

回头看整个项目,有几个决策我会再做一次:

Toggle 模式而不是 press-and-hold。 最初的设计是按住键录音、松开停止。改成 toggle(按一下开始,再按一下停止)有两个原因:录音可能持续几十秒,一直按着手酸;toggle 模式下用户可以双手离开键盘说话,更自然。双击取消是 toggle 模式的免费赠品——快速按两下,录音作废,回到 idle。

砍掉翻译功能。 最初想做转写 + 翻译,意味着用户要配两个 API key(转写用 Whisper,翻译用 GPT)。后来想了想,我自己也不常用翻译。砍掉之后只需要一个 key,配置步骤少了一半,代码复杂度少了三分之一。功能少了,体验好了。

用 MacPaw/OpenAI SDK 而不是手写 HTTP。 第一版手写 multipart/form-data 请求,花了不少时间处理编码边界。换成 MacPaw 的 Swift SDK 后,一个函数调用搞定,错误处理也更完善。在这种"不是核心竞争力"的地方,用社区方案省时间。

远程 API 而不是本地模型。 试过 WhisperKit,500MB 模型下载到本地运行,离线可用。但中英文混合输入时频繁把中文识别成英文。远程 API 的准确率高一个档次,延迟在可接受范围内(2-4 秒)。成本也不高——下面细算。


成本

默认使用 gpt-4o-mini-transcribe

使用量 费用
1 分钟(约 150 字) $0.003
每天 30 分钟,一个月 $2.70
每天 5 分钟,一个月(我的实际用量) $0.45

对比 Typeless 的 $12/月。即使重度使用,成本也差了一个数量级。


反思

这个项目最大的教训:不要低估"简单"的事情。

“按键、录音、转文字、粘贴”——一句话说完的需求,实际涉及 CGEvent tap、Accessibility API、AVAudioRecorder、code signing、actor isolation——每一层都有自己的脾气,而且它们互相打架。快捷键监听和快捷键录制打架,SwiftUI 和 CGEvent tap 打架,Accessibility API 和不同应用的实现打架。

更深的感受是,很多时间不是花在"做功能"上,而是花在"理解系统"上。 权限为什么失效、录音为什么报错、浮窗为什么崩溃——答案都不在代码里,在对 macOS 底层机制的理解里。这种认知补课没有捷径,只能一个坑一个坑地踩。

最后一点:先用笨办法。 AVAudioRecorder 比 AVAudioEngine 笨,AppKit 比 SwiftUI 笨,剪贴板粘贴比 Accessibility API 笨。但它们都是最终活下来的方案。每次我试图一开始就用"更优雅"的方案,都绕了远路。先跑通,再优化——不是空话,是这个项目反复验证的结论。

MIT 开源:github.com/scinttt/open-typeless-formac