兩年前,我把 KeyEcho 開源了。那是一個小型桌面應用程式,當你按下按鍵的瞬間,就會播放機械鍵盤的聲音。它拿到了 800 多顆星。之後我在 2024 年 7 月發佈了 v0.0.5,接著就沉寂了。
問題從未停止。大家開始詢問音效包、回報平台 bug,並持續使用這個我已經停止維護的工具。這個月,我回來並在一個 PR 中直接推出了 1.0:130 個檔案、增加 11,405 行、刪除 9,292 行。核心是把音訊熱路徑整個重建。快取查找的微基準測試從每次操作 1184.07 ns、每次按鍵複製 66.84 KiB,降到 43.50 ns、且不複製任何取樣位元組。平均切片快了 27 倍,最大切片快了 38 倍。
我是在 AI agent 寫了大量程式碼的情況下完成這件事的。這篇文章會說明這次重構是怎麼運作的、為什麼 Rust 讓它能又快又安全地做到,以及 agent 建議的一個看起來很乾淨、卻會悄悄破壞功能的改動。
KeyEcho 對延遲敏感的路徑很短。全域鍵盤 hook 會在每次按鍵事件時觸發。每次按下的第一個 key-down 事件會被送進有界佇列。音訊執行緒會從佇列取出資料,把按鍵映射到所選音效包中的某個切片,然後在本機播放。
因為這條路徑很短,任何留在其中的工作都會在每次按鍵時付出代價:任何解碼、任何配置、任何取樣複製、任何鎖。整個重點就是把這些東西從每次按鍵的路徑中移除。
先說清楚這點,v0.0.5 確實不錯:Tauri + Rust、建置小、記憶體用量低;我手寫了各平台原生鍵盤監聽,幾乎沒有使用 unsafe;使用無損 WAV 音訊,沒有解壓步驟;而且已經在 LRU 中快取了解碼後的音訊,所以同一個按鍵按兩次不會解碼兩次。它跑了兩年,也有幾百人使用。這不是慢軟體。
但「已經很快」和「沒有進一步優化空間」是兩回事。即使是快取命中,每次按鍵仍然會複製取樣(在基準測試中是 66.84 KiB),而且會經過一個與音效包切換和音量變更共用的全域 mutex。快取未命中時,還是會當場解碼。1.0 移除的正是這些:那個複製、那把鎖,以及快取未命中這件事本身。
這次重建主要靠四個動作。
在選取音效包時一次性預先解碼。 音效包是一個資料夾,裡面有 sound.ogg 和 config.json。設定檔會把每個按鍵對應到音訊切片:key -> [start_ms, duration_ms]。當你選擇某個音效包時,所有切片都會一次預先解碼。按鍵時不再進行任何解碼。
去重相同切片。 很多按鍵會指向同一個切片,所以如果每個按鍵都各自解碼,會重複解同一段音訊很多次。建構器會用 (start_ms, duration_ms) 這組值當作 map 的 key,把每個唯一切片只解碼一次,然後共用它:
// 每個唯一切片只解碼一次;相同切片共用同一個緩衝區。
let mut slice_sources: HashMap<[u64; 2], AudioSource> = HashMap::new();
let mut key_sources: HashMap<Key, AudioSource> = HashMap::new();
for (key, slice) in defines {
let source = slice_sources
.entry(slice)
.or_insert_with(|| {
let [start_ms, duration_ms] = slice;
let samples = decode(start_ms, duration_ms); // Vec<f32>,只做一次
AudioSource::new(Arc::from(samples), channels, sample_rate)
})
.clone(); // Arc 複製,不是取樣複製
key_sources.insert(key, source);
}
零拷貝播放。 解碼後的取樣會存在 Arc<[f32]> 中。查找按鍵時只會複製這個 Arc,也就是引用計數加一,而不是複製取樣。每次按鍵的複製成本變成 0 位元組。
移除全域 mutex。 舊路徑用 mutex 保護目前音效與音量。新的播放 handle 把目前音效放在 ArcSwapOption<KeySound> 中,把音量放在 AtomicU32 中(透過 f32::to_bits)。查找按鍵時是無鎖讀取:
struct PlaybackSoundpack {
current_sound: Arc<ArcSwapOption<KeySound>>,
volume_bits: Arc<AtomicU32>,
}
fn source_for_key(&self, key: Key) -> Option<(AudioSource, f32)> {
let sound = self.current_sound.load(); // 無鎖
let source = sound.as_ref()?.key_source(key)?; // map 查找 + Arc 複製
let volume = f32::from_bits(self.volume_bits.load(Ordering::Relaxed));
Some((source, volume))
}
預先解碼是把前置工作換成記憶體用量,所以需要兩道保護。鍵盤事件會經過有界佇列,因此短時間大量輸入會形成反壓,而不會讓記憶體無限制成長。另外,一個音效包必須符合 10 MiB 的已解碼取樣預算:在載入前,程式會根據唯一切片的持續時間估算解碼後大小,若超過就拒絕載入。只有在有界的情況下,預先解碼才是安全的。
release build 下,查找路徑的微基準測試如下:
| 路徑 | 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,基準測試也隨 repo 一起發布。如果你不相信我,可以執行 pnpm run bench:audio。
這次 diff 的大部分,我都交給 agent 來做。之所以覺得安全,是因為有編譯器。
借用檢查器與型別系統,是 AI 產生程式碼的第一道審查。大多數錯誤程式碼都過不了 cargo check。把共享音訊緩衝區改成 Arc<[f32]>、並移除 mutex,正是那種一旦出錯就會變成別名問題或 Send/Sync 錯誤的改動;而 Rust 會在編譯階段就把這些拒絕掉,甚至還沒到人工審查或使用者手上。agent 參與的 diff 越多,這種防線就越有價值。
不是「它寫了整個 app」。真正有用的工作更窄,也老實說更有價值。
我很少下逐步指令。我給的是目標(像是「把按鍵路徑中的樣本複製降到 0」、「把待辦清單按屬於 1.0 的內容排序」),然後在檢查點審核。速度提升本身來自重構:預先解碼、共享緩衝區、移除 mutex、有界佇列。agent 則是讓這整個找出、執行、驗證的流程快到真的能完成。
這個我希望你學起來。
Linux armv7 的 CI 建置在 QEMU 下失敗了:libgit2 無法抓取 git 依賴。agent 提出的修正很乾淨:把音訊 crate 上的 git pin 拿掉,改用 crates.io 上的 release。CI 變綠了。
但我拒絕了。那個 pin 的存在有其理由,而且程式碼裡哪裡都沒寫。它把 cpal 鎖在 0.18,而這個版本提供預設裝置重新導向(以及 DeviceChanged 通知),讓你拔掉耳機或切換到藍牙喇叭時,聲音會跟著切過去。改回 crates.io 版本會悄悄把 cpal 降到 0.17.3,並失去裝置跟隨功能。兩個使用者問題,#41 和 #20,談的就是這個行為。沒有任何測試涵蓋「拔掉裝置後聲音仍能跟著走」,所以如果不是知道那個 pin 為什麼存在,根本不會有人發現這個 regression。
真正的修正是用 cargo vendor 把相依套件供應到本機,然後在 QEMU 內離線建置。那個 pin 從頭到尾都沒動。
我現在把兩件事當成規則:
armv7 的建置在模擬器下大約要跑兩個小時,而且看不出有清楚的 32 位元 ARM Linux 使用者。我把它從 1.0 中移除了。
agent 不會有沉沒成本或機會成本的概念。只要你放任它,它會永遠持續最佳化某個建置目標。它可以無限接近「完成」。決定何時停止,這是人的工作。
再補充兩個這次發佈時的小觀察。
正式發佈前,我在 git worktree 裡跑了 AI 審查流程,這樣多個 agent 就能平行掃描程式碼並提出修正,而不會碰到主樹。每個 agent 的輸出都只是提案,必須經過審查後才能合併。探索可以很乾淨地平行化,但整合仍然必須在一個人腦中完成,因為每個 agent 只看得到自己那一小塊。
模型也開始分工了。Claude Fable 給我的感覺像一位會跳脫框架思考的工程師,常常能帶出我忽略的角度。GPT-5.6 是我用過最強的執行者:你只要把已決定好的計畫交給它,回來的程式碼幾乎完美。整體感覺就像在帶一個兩人團隊:一個負責思考,一個負責實作,而我負責決策與簽核。
還是免費,還是開源。AGPL-3.0、精簡的原生安裝程式、沒有帳號、沒有分析追蹤。基於 Tauri 2 + SolidJS 重建。Windows 安裝檔有簽章,macOS 安裝檔有公證,減少了未簽章應用程式警告和防毒軟體誤判。Linux 提供 x64 與 ARM64 套件。
你甚至不用下載任何東西就能試:打開 keyecho.app 並開始打字,你就會在瀏覽器裡聽到聲音。基準測試與方法都在 repo 裡。
我每週會寫一篇建置日誌,記錄如何用 AI agents 交付真實的 production system,只講真實資料。歡迎訂閱 upweb.dev。