两年前,我开源了 KeyEcho,一个在你按下按键时立即播放机械键盘音效的小型桌面应用。它获得了 800+ 星标。随后我在 2024 年 7 月发布了 v0.0.5 版本,然后就沉寂了。
问题从未停止。人们要求音效包,报告平台相关的 bug,并且持续使用这个我已经停止维护的东西。这个月我回来了,并通过一个 PR 发布了 1.0 版本:130 个文件,新增 11,405 行代码,删除 9,292 行代码。其核心是对音频热路径的重构。缓存查找的微基准测试从每个按键复制 66.84 KiB 的 1184.07 ns/op 提升到了零样本字节复制的 43.50 ns/op。平均切片性能提升了 27 倍,最大切片性能提升了 38 倍。
我使用 AI 代理编写了大量代码。这篇文章将介绍这次重构的工作原理,为什么 Rust 能安全地实现高速运行,以及代理建议的一个看似简洁但会悄然破坏功能的变化。

热路径
KeyEcho 的延迟敏感路径很短。一个全局键盘钩子会在每个按键事件触发时启动。每次按键的首次按下事件被推入一个有界队列。一个音频线程从队列中取出事件,将按键映射到所选音效包的一个切片,并在本地播放。
由于路径很短,路径中的任何操作都会在每个按键时产生开销:任何解码工作、任何内存分配、任何样本复制、任何锁。整个游戏的目标就是将这些操作从每个按键的路径中移除。
v0.0.5 已经很快了
公平地说,v0.0.5 是一个轻量、快速的东西:Tauri + Rust,构建体积小,内存占用低;我针对每个平台的 API 手动编写了原生键盘监听,几乎没有使用 unsafe;无损 WAV 音频,无需解压步骤;并且它已经在 LRU 缓存中缓存了解码后的音频,因此重复按下同一个键不会重复解码。它运行了两年,有几百人使用过。它并不是一个慢的软件。
但“已经很快”和“没有优化空间”是两码事。即使在缓存命中的情况下,每次按键仍然会复制样本(基准测试中为 66.84 KiB),并且需要通过一个与音效包切换和音量更改共享的全局互斥锁。缓存未命中时仍然会当场解码。1.0 版本移除的正是这些:复制操作、锁,以及缓存未命中的情况。
重构
重构归结为四个步骤。
- 预解码一次,在选中音效包时进行。一个音效包是一个包含
sound.ogg和config.json的文件夹。配置文件将每个按键映射到音频的一个切片:key -> [start_ms, duration_ms]。当你选择一个音效包时,所有切片都会被预先解码一次。按键过程中不会进行任何解码。 - 去重相同的切片。许多按键指向同一个切片,因此按每个按键解码会多次解码相同的音频。构建器使用
(start_ms, duration_ms)对作为映射的键,每个唯一切片只解码一次,并共享它:
// 伪代码示例
let mut unique_slices: HashMap<(u64, u64), Arc<[f32]>> = HashMap::new();
for (key, (start, duration)) in config.key_map.iter() {
let slice = unique_slices.entry((*start, *duration))
.or_insert_with(|| decode_slice(&sound_data, *start, *duration));
key_to_sound.insert(key, slice.clone());
}
- 零拷贝播放。解码后的样本存在于
Arc<[f32]>中。按键查找返回该Arc的一个克隆,这只是一个引用计数的增加,而不是样本复制。每个按键的开销降为零字节复制。 - 移除全局互斥锁。旧的路径使用互斥锁保护当前音效和音量。新的播放句柄使用
ArcSwapOption<KeySound>持有当前音效,并使用AtomicU32(通过f32::to_bits)持有音量。查找按键是一个无锁的加载操作:
// 无锁加载示例
let current_sound = self.sound.load();
let volume = f32::from_bits(self.volume.load(Ordering::Relaxed));
if let Some(sound) = current_sound.as_ref() {
// 播放 sound 的切片
}
预解码将工作提前到加载阶段,以换取 RAM,因此需要两个护栏。按键事件通过一个有界队列,因此突发按键会产生背压,而不是无限制地增长内存。并且一个音效包必须符合 10 MiB 的解码样本预算:在加载之前,应用会根据唯一切片的持续时间估算解码后的大小,并拒绝任何更大的包。只有当预解码是有界的时候,它才是安全的。
数据
查找路径的发布构建微基准测试:
| 路径 | v0.0.5 | v1.0 | 变化 |
|---|---|---|---|
| 缓存查找,平均切片 | 1184.07 ns/op; 66.84 KiB 复制 | 43.50 ns/op; 0 字节复制 | 快 27.2 倍 |
| 缓存查找,最大切片 | 1638.57 ns/op; 98.88 KiB 复制 | 43.10 ns/op; 0 字节复制 | 快 38.0 倍 |
| 按下/释放门控 | 58.82 ns/tap; 2 条消息 | 54.69 ns/tap; 1 条消息 | 消息量减半 |
这些是查找路径的微基准测试,不是端到端的扬声器延迟,后者取决于你的操作系统和硬件。方法和内存预算在 docs/performance.md 中,基准测试代码也包含在仓库中。如果你不相信,可以运行 pnpm run bench:audio。
为什么 Rust 能安全地实现高速运行
我主要依赖代理来完成这次代码差异的大部分工作。感觉安全的原因是编译器。
借用检查器和类型系统是 AI 生成代码的第一道审查者。大多数错误的代码无法通过 cargo check。将共享音频缓冲区迁移到 Arc<[f32]> 并移除互斥锁,正是那种错误会导致别名问题或 Send/Sync 错误的更改,而 Rust 在编译时就会拒绝这些错误,在它们到达人工审查或用户之前。代理编写的代码差异越多,这个护栏的价值就越大。
代理实际做了什么
不是“它编写了整个应用”。有用的工作范围更窄,老实说,也更有价值。
- 迁移助手。从 Tauri 1 到 2 的迁移涉及配置、权限、更新器和每个插件边界。一个已经阅读了所有迁移文档的伙伴可以节省大量时间。
- 热路径审计员。它和我一起逐行检查了旧的播放路径,并推动了上述的预解码、共享缓冲区、无互斥锁的重构。
- 积压问题分类。我让它阅读每个未解决的问题,并按哪些属于 1.0 版本进行分类。其中一个是一个 10 个月前的请求,来自一个愿意为特定音效付费的人。在 1.0 版本后回复他,结果变成了项目的第一个付费客户。积压的问题是我不再阅读的需求。
- 基准测试和文档规范。它保持了性能说明的结论优先、可重现,并且诚实地说明了数据衡量了什么以及没有衡量什么。
我很少给出逐步指令。我给出目标(“将按键路径中的样本复制降至零”,“按哪些属于 1.0 版本对积压问题进行分类”),并在检查点进行审查。性能提升本身来自重构:预解码、共享缓冲区、移除互斥锁、有界队列。代理的作用是让发现、执行和验证整个循环变得足够快,以至于能够实际发生。
那个看似简洁但并非如此的更改
这是我想让你借鉴的一点。
Linux armv7 构建在 QEMU 下失败:libgit2 无法获取 git 依赖项。代理的修复很简洁。移除音频 crate 的 git 固定版本,改用 crates.io 上的发布版本。CI 变绿了。
我拒绝了它。那个固定版本的存在是有原因的,但代码中没有任何地方写明。它固定了 cpal 到 0.18 版本,该版本提供了默认设备重路由(及其 DeviceChanged 通知),当你拔掉耳机或切换到蓝牙音箱时,音效会跟随你。回退到 crates.io 版本会静默地将 cpal 降级到 0.17.3,并移除设备跟随功能。两个用户问题,#41 和 #20,正是关于这个行为的。没有测试覆盖“拔掉设备后音效跟随”的情况,因此除了知道固定版本的原因外,没有任何东西能捕获这个回归。
真正的修复是使用 cargo vendor 来提供依赖,并在 QEMU 内离线构建。固定版本从未移动。
我现在将其视为规则的两条教训:
- 在每个固定版本、hack 和魔法数字旁边,在注释或项目的 AI 规则文件中写下“为什么”。你的代理没有关于导致它存在的事件的记忆。它是一个才华横溢的工程师,但对你项目的上下文一无所知。
- 永远不要接受你未要求的依赖版本更改,即使 CI 是绿色的。绿色的流水线证明测试通过了。它不能证明测试覆盖了你即将失去的东西。
我砍掉了什么
armv7 构建在模拟下运行了大约两个小时,而且没有明确的 32 位 ARM Linux 用户。我将其从 1.0 版本中移除。
代理没有沉没成本或机会成本的概念。如果你允许,它会永远优化一个构建目标。它可以无限接近“完成”。决定停止是人类的工作。
工作是如何组织的
发布这个版本的另外两个小注意事项。
发布前,我在git工作树中运行了一轮AI审查,多个代理可以并行扫描代码并提出修复建议,而不会影响主分支。每个代理的输出都是一个提案,只有在审查通过后才会合并。探索过程可以干净地并行化,但整合仍需由一个人完成,因为每个代理只看到自己负责的部分。
模型也开始出现专业化分工。Claude Fable像一位跳出框架思考的工程师,总能发现我忽略的角度。GPT-5.6则是我用过最好的执行者:交给它一个确定的方案,返回的代码几乎完美无缺。这就像运行一个两人团队——一人思考,一人构建,而我负责决策和最终确认。
1.0版本是什么
依然免费、开源。采用AGPL-3.0协议,提供小型原生安装包,无需账户,无数据追踪。基于Tauri 2 + SolidJS重建。支持签名的Windows构建和公证的macOS构建,减少了未签名应用警告和杀毒软件误报。提供x64和ARM64架构的Linux包。
无需下载即可试用:打开keyecho.app并输入文字,就能在浏览器中听到声音。基准测试和方法论都在代码仓库中。
我每周发布一篇构建日志,记录用AI代理交付真实生产系统的过程,只展示真实数据。订阅请访问upweb.dev。