有人在 Zoom 通话中说了一个 URL 昨天。我还在写下前一个要点。等我抬头时,他们已经继续了。我把自己静音,在聊天框里输入了 “抱歉,你能重复一下链接吗?”,然后等待。
三十秒后他们还是把它粘贴了过来。
我不需要他们重复。我需要的是内存中保存的最近三十秒音频,随时可以回放。不是我忘记开启的屏幕录制。不是云端的 Otter。也不是在两小时的讲座文件里来回查找。
于是我开发了 Huh?,一个 macOS 菜单栏应用,它在内存中保存你正在听取的内容最近 30 秒到 2 分钟,并在你按下 ⌥⌘H 时回放。
我最初在 X 上发布了一个短片段。几个人问是否愿意为它付 5 美元。这篇帖子是更详细的回答:它的功能是什么,我在开发过程中遇到了哪些问题,以及在我认为可以正式发布之前,我仍在完善的部分。
演示
这就是起点的那条推文。我在会议、通话、播客以及一切场合里一直说着 “等等,你刚才说什么?” 于是才开发了这个 Mac 应用。
YouTube 上的完整演练:
这个东西实际上是什么
Huh? 驻留在菜单栏。它会启用一个滚动音频缓冲区,可以是单个应用的系统音频、全部音频或麦克风输入。你继续聆听。当你漏掉某些内容时,按下 ⌥⌘H。最近的 N 秒会立即回放。你可以将播放速度调慢到 0.75x 或 0.5x,音高不变。如果你漏掉的是一个名字或 URL,而不仅仅是声音,你还可以读取设备上的转录文本。
除非你通过 ⌘S 显式保存一个片段,否则不会有任何内容写入磁盘。不会有任何内容被上传到任何地方。该应用唯一能发起的网络请求是下载 Whisper 模型,且仅在你在设置中点击 Download 时才会发生。
这就是完整的产品循环。其余的都是工程工作,以使这个循环感觉即时且不令人不安。
我为什么不直接使用现成的东西
在我写下第一行 Swift 代码之前,我曾试图说服自己不要构建这个功能。以下是我实际考虑的方案。
屏幕录制
屏幕录制是一种承诺。你必须在事情发生前就开始录制。它会写入磁盘。对于“我漏掉了一句话”这种情况,感觉太重了。
嗯?默认情况下是回溯式的。缓冲区已经存在。你不是在决定录制,而是在决定回放。
云端转写(Otter 等)
这些工具很强大,但不适合此场景。
我不想上传会议音频。我不想再注册一个账号。我不想在通话中加入机器人。我希望有一个热键,能够在我独自凌晨一点观看讲座时使用。
于是约束变成了:仅在设备端进行。转写功能包含在内,但除非我导出片段,否则 ничего 不会离开我的 Mac。
“只需暂停并倒回”
YouTube 有倒退按钮。Zoom 没有。播客应用虽然有,但做得很差。一个菜单栏工具能够跟随你在不同应用之间使用。这就是重点。
你实际上可以用它做什么
在架构图之前,先看看使用它的感觉是什么。
选择要聆听的内容。 单个应用(Chrome、Zoom 或其他),系统播放的所有声音,或麦克风输入。缓冲区大小根据你的设置而定,范围为 30 秒至 2 分钟。
使用一个热键即可回放。 ⌥⌘H 打开 HUD 并从缓冲区末端开始播放,也就是你刚刚错过的那一刻。无需文件选择器。也无需担心“我把录音存哪了”。
放慢速度而不失真。 0.75x 和 0.5x 通过 AVAudioUnitTimePitch 运行,使音高保持自然。当有人以会议速度朗读 URL 时很有用。
阅读你错过的内容。 有两个设备端引擎:Apple 的 SFSpeechRecognizer(无需设置)或通过 WhisperKit 使用 Whisper(一次性模型下载,在名称和 URL 上表现更好)。点击单词可跳转。转录在回放期间会滚动并高亮活动单词。
如果需要,保存一个片段。 ⌘S 导出为 AAC(WAV 作为后备)。这是从缓冲区到文件系统的唯一路径。特意如此。
我为何将其命名为 “Huh?”
因为这是我大脑在嘴巴说出 “对不起,你能再说一遍吗?” 之前说的确切词。
我尝试的其他名称听起来都像企业软件:
- ReplayBuffer.app:准确,但夭折。
- Audiograph:听起来像每年 400 美元的仪表盘。
- Last30:描述性强,却易忘。
Huh? 有点傻。这就是它能够留下的原因。
现在进入技术部分
技术栈:Swift,在 NSPanel 中的 SwiftUI,CoreAudio 进程捕获,用于回放的 AVAudioEngine,可选的 WhisperKit。使用 Swift Package Manager 和命令行工具构建,无 Xcode 项目。build.sh 手动组装 .app 包。
捕获:CoreAudio 实时线程上的环形缓冲区
Huh? 在单个应用、全部或麦克风上使用 CoreAudio 进程捕获(CATapDescription)。音频以单声道 Float32 PCM 形式到达,并进入无锁环形缓冲区。
RingBuffer.write 中的所有内容都在 CoreAudio 的 IO 线程上运行。该路径上没有锁、没有分配、没有日志。存储是一个在 init 中一次性分配的原始指针;唯一的共享状态是原子操作。没错,IOProc 上有一个 unowned(unsafe) 的 sink。
/// Everything in `write(bufferList:)` runs on the realtime IO thread. A single
/// allocation, lock, `os_log` call or ARC retain in that path is an audible
/// glitch, not a style problem.
final class RingBuffer: @unchecked Sendable {
private let framesWritten = Atomic<Int>(0)
// ...
}
在30秒的缓冲区下,你大约占用5.5 MB的内存。在48 kHz源下运行2分钟时,大约占用22 MB。这是在8 GB Mac上重要的数字。
发布版本是通用的(arm64 + x86_64 通过 lipo)。仅arm64的二进制文件在Intel上不会运行缓慢,而是会因CPU类型错误而拒绝启动。该网站曾说“Apple silicon 和 Intel”,因此仅发送原生构建将是撒谎。
回放:蓝牙耳机错误
Replay 通过 AVAudioEngine → AVAudioUnitTimePitch 运行。
这个耗费一周的无聊错误:当系统音频设备变化时播放不同步。在回放过程中连接蓝牙耳机,UI 认为你在 0:18,而音频实际上在别处。暂停有时会显示“继续”,而音频仍在播放,尤其是在后台系统捕获已启用时。
修复:从播放器的采样时钟驱动播放头,而不是墙时钟。使用生成令牌,使过期的完成处理程序不会与当前播放冲突。使用 .dataPlayedBack 进行播放结束检测,而不是假设节点在你告诉它停止时已经停止。
转录:Apple 的识别器在长音频上说谎
这件事让我很生气。
SFSpeechRecognizer 并设置 requiresOnDeviceRecognition = true 看起来能处理长缓冲区,但实际上不行。超过大约一分钟后,它只返回开头,而后续词的时间戳是胡说八道。
在连续语音 107 秒的测量中,它报告了 0.00–30.03 然后是 60.06–83.76。后半段被叠加在前半段上。点击末尾附近的单词会跳转到错误的时间点。
由于 Huh? 支持两分钟的缓冲区,这注定是一个坏掉的功能。
修复:将其分割成约 40 秒的块,在每个边界附近最安静的点分割,顺序转录,并偏移时间戳。完整性和搜索准确性随之恢复。
/// Past roughly a minute `SFSpeechRecognizer` starts returning only the
/// first part of what it heard, and the timestamps it reports for later
/// utterances stop meaning anything.
private static let chunkSeconds: Double = 40
在任意引擎处理音频之前,AudioPrep 会先进行高通滤波并归一化增益。峰值仅为 0.02 的讲座音频曾经只返回半份转录文本,原因并非神秘,只是音量太低。
| Apple | Whisper (WhisperKit) | |
|---|---|---|
| 设置 | 无 | 一次性模型下载 |
| 186 秒语音(测量) | 427 词,约 25 秒 | 450 词,约 1.8 秒(在 M 系列上) |
| Intel Mac | 是 | 否,隐藏在设置中 |
Whisper 在 x86_64 切片上并未优雅降级。它在触及模型的瞬间发生段错误。通过运行同一通用二进制文件的两个切片进行验证:arm64 退出码 0,x86_64 退出码 139,在模型加载时崩溃。崩溃比缺少菜单项更糟,因此在 Intel 平台上,引擎选择器根本不显示 Whisper。
导致完美沉默的签名错误
如果你使用 ad-hoc 方式进行代码签名(codesign -s -),macOS 将允许你的音频捕获运行并输出静音。没有提示。没有错误。仅仅是零。
state: armed
peak: 0.0000 # every sample zero
TCC 将权限绑定到签名身份。临时身份没有权限,因此 macOS 永不会弹出提示,也不会记录决定,而该功能看起来正常却毫无作用。我在相信这一点之前浪费了好几个小时。
开发时,Tools/dev-signing-identity.sh 会生成一个本地证书。对外部用户,我仍然需要 Developer ID 和公证,这是当前的发布阻塞点。
内存,针对全天驻留在菜单栏的内容
菜单栏应用全天驻留。它所保存的内容比其峰值使用更重要。
--memory-report 会遍历整个路径并在每一步打印占用情况。在 M1 Pro,16 GB 内存,配有 120 秒缓冲区:
| 状态 | 内存占用 |
|---|---|
| 启动后空闲 | ~13 MB |
| 120 秒环形缓冲区已满 | +16 MB |
| Apple 转录稳定后 | ~46 MB |
| Whisper Base 已加载 | ~200 MB |
| Whisper 在空闲后被驱逐 | ~90 MB |
Whisper 管道在最后使用约 150 秒后会被释放。仅因为午餐时转录一次而让 1.5 GB 的大模型常驻内存,对实用工具应用来说是不可接受的。
我还删除了两个冗余的拷贝:ReplayEngine 曾经保存完整的 AVAudioPCMBuffer(整个快照),并在每次查找时再次切片。AudioPrep 曾经在重采样到 16 kHz 前先以 48 kHz 复制整个窗口。如今两者都基于切片工作。事后看来这显而易见,但在 --memory-report 显示残留在多次遍历中上升时并不明显。
在我称其为已发布之前还有哪些工作?
- [ ] Apple 公证:在我的机器上可用,其他人需要 Developer ID +
notarytool。
- [ ] 在 macOS 15 硬件上测试:基于 15.x SDK 构建,我的大部分内部测试是在较新的构建上进行的。
- [ ] Sparkle 或类似工具用于更新。
- [ ] 与真实 HUD 匹配的登陆页演示。
notarytool。我宁愿现在就说出来,也不想让第一个付费用户遇到 Gatekeeper 然后离开。
您愿意付 $5 吗?
这就是我在 X 上提出的问题,我是认真的。
嗯?这不是周末黑客项目。这是我花了数月时间追踪为什么蓝牙上的暂停会不同步,以及为什么 Apple 的转写会在时间戳上说谎。但它也是一个菜单栏实用工具,而不是团队计划。我不想把它定价像 SaaS 租金一样。
我的当前想法:
- $5 对于每周都感受到此问题的早期采用者来说是一个很容易的“是”。
- 完成公证和打磨后,$12 到 $15 可能是它的定价。
- 网站上的文案今天写的是 $24,如果受众是整天在通话中的专业人士,这可能是正确的。
如果你已经读到这里:你会为讲座、会议、播客付费吗?这在我打开购买按钮之前是一个真正有用的信号。
试一试(即将)
X 演示:twitter.com/VarshithVhegde1/status/2098771582688907506
如果你想在它发布时获得早期访问权,请留言或给我发邮件:varshithvh@gmail.com。告诉我你会用它来做什么。




