当AI Coding遇上极限性能优化
0x00 效果预览
0x01 梦开始的地方
故事要从去年10月说起,我在B站上刷到这类视频:

我最先看到的一条视频有十多万的播放量,视频以俯拍视角,对准桌面上摆放的一个半巴掌大小的音乐键盘。视频中,博主用轻巧的手法,按下键盘上的按钮,弹奏出一些网络当红的歌曲。通过编辑标题和封面,视频能够吸引很多本身就喜欢这首音乐的观众点进来,并停留较长的时间,得到不错的完播率,进而使视频能够通过推荐算法吸引到更多观众。
看到视频下的评论区,我发现有很多观众通过这些视频种草了这款音乐键盘。出于好奇,我在多家网购平台搜了一下这款键盘的价格,发现这东西比我想象中要贵得多——基础款两三百起步,如果是带有不同外壳和专属内置音色的联名款,价格更是直接翻倍。而且评论区里不止一个人提到,按键按下去会有塑料碰撞的杂音,影响使用体验。于是我便想,既然这样,为什么不自己设计、做一个出来呢?但无奈苦于人在国外,各种加工、元件采购价格和时间成本都太高,我决定退而求其次,先通过Web复刻一个软件版本的出来。
0x02 音乐合成哪家好
有了技术方向,接下来就要看怎么实现发声,经过一番AI搜索,了解到了以下几种音乐合成方案:
| 对比维度 | 采样播放 | 波表合成 | 物理建模 |
|---|---|---|---|
| 声音质感 | 最接近真实乐器 | 偏电子合成器 | 逼真、可交互 |
| 资源体积 | 大 多音色多样本 | 小 一张波表即可 | 几乎无音频资源 |
| 加载与首屏 | 慢 需下载解码 | 快 可内联 | 快 无音频加载 |
| 音高变化 | 需要在采样数量和失真权衡 | 实时合成 | 实时合成 |
| 计算压力 | 轻 | 中等 | 重 |
| 实现复杂度 | 低 | 中 | 高 |
| 音色可调性 | 弱 | 强 | 很强 |
| 适合场景 | 真实乐器音色库 | 网页轻量合成器 | 强交互乐器模拟 |
在合成效果和性能成本的权衡之下,我最后选择了“波表合成”这套方案,并选定将Vital这款开源的C++ 波表合成器,通过Web Assembly迁移到Web平台。
0x03 Vibe Coding
项目开发的启动时间是2025年12月,那时GPT-5.2刚刚发布,在简单试用后,我意识到那时LLM的发展进度,已经能让AI Coding的体验,从最开始的代码Tab键补全、Chatbox完成指定任务的开发,到具有初步具有Agent能力,可以自己通过生成TODO规划、工具调用、查日志、修Bug的方式,端到端地完成一个完整功能模块,甚至是一个完整应用的开发了。
于是,我先自己列了一个简单的分阶段开发计划:
- 完成Vital音频合成引擎的Web Assembly迁移
- 开发Web Demo并通过Web Assembly接入Vital
- 在PC Chrome环境测试Web Demo可以正常运行
- 优化性能,确保在移动端平台(如:Android、iOS)上Web Demo也能流畅运行
在开发环境上,我选择在我的本地电脑使用Codex + GPT-5.2模型完成Vital的Web Assembly迁移,通过将代码仓库推送到Github,自动触发Actions编译。这样一来,就不需要在本地开发环境重新配置一套Emscripten编译工具链。
当时给到Codex的prompt如下:
1 | |
由于是第一次尝试让AI独自从零开始完成一个项目的开发工作,整个迁移+调试的过程大概花了三个晚上左右的时间。一开始在Github Actions编译WASM遇到问题时,我需要手动把编译报错信息从Github网页复制粘贴到Codex中让其修复。但随着后面发现这种重复性工作非常繁琐,我便修改流程为:让AI写了一个Python脚本,通过请求Github OpenAPI获取Actions的运行结果。通过把整个编译测试流程串起来,我们实现了一部分的端到端开发流程。
在通过手动测试demo,确认Vital WASM验收通过后,我将编译产出的wasm文件、js胶水代码、AI整理的SDK文档转交给了Claude Sonnet 4.6,让其完成了第一个版本的音乐键盘开发:

0x04 性能瓶颈
在电脑上的Chrome浏览器里跑通后初版音乐键盘后,我信心满满地在手机的Chrome浏览器里打开同一个页面一试,出乎意料的是——体验糟糕到难以接受:
- 从按下按键,到喇叭发出声音之间的延迟体感明显,操作严重不跟手
- 在电脑Chrome中用鼠标操控时,同时只会触发一个音播放,但是手机触摸屏支持多点触控,一旦同时按下两个以上的按钮,性能占用直接拉满,甚至出现爆音的情况
- 尝试加载复杂音色时,性能压力更大,甚至UI也会出现卡顿、闪烁等现象,如果有延音等效果,基本无法正常使用
方案1: ScriptProcessorNode
经过一番和AI的友好沟通后,我得知到其为了更快的完成应用开发、并确保拥有广泛的兼容性,在音频渲染管道使用了ScriptProcessorNode这个feature。但是由于ScriptProcessorNode本身和渲染主线程同时运行,对于CPU单核性能要求高。然而移动端处理芯片往往受功耗和散热限制,CPU单核性能相比PC桌面端差距很大。因此,一旦音频回调还跑在主线程上,延迟和爆音就会被立刻放大:
flowchart TB
classDef ui fill:#1e3a5f,stroke:#38bdf8,color:#e0f2fe
classDef proc fill:#4c0519,stroke:#fb7185,color:#fff1f2
classDef eng fill:#14532d,stroke:#4ade80,color:#dcfce7
classDef sink fill:#292524,stroke:#a8a29e,color:#fafaf9
subgraph Main["主线程"]
direction TB
UI["键盘按钮<br/>按下 / 抬起"]:::ui
State["当前按下音符状态"]:::ui
CB["ScriptProcessorNode<br/>onaudioprocess"]:::proc
WASM["Vital WASM<br/>按状态渲染一帧"]:::eng
Out["写入 outputBuffer"]:::eng
UI -->|更新| State
State -->|返回| CB
CB -->|读取| State
CB --> WASM --> Out
end
Out --> Dest["AudioContext.destination"]:::sink
Dest --> Spk["扬声器"]:::sink
方案2: AudioWorklet
通过继续追问AI,我了解到在Web Audio中,现在更推荐使用能够在独立线程中运行、充分利用CPU多核性能的AudioWorklet:
| 对比维度 | ScriptProcessorNode | AudioWorklet |
|---|---|---|
| 线程模型 | 主线程 | 独立音频线程 |
| 性能发挥 | 和 UI 抢同一核 | 音频与 UI 分核 |
| 浏览器内核兼容 | Chrome 24+ Firefox 25+ Safari 7+ |
Chrome 66+ Firefox 76+ Safari 14.1+(iOS 14.5+) |
| API 状态 | 已废弃 | 现行推荐 |
| 接入成本 | 低 | 中 |
有了优化方向后,很快,AI就把优化后的新版本端上了,在这个版本中,引入了使用AudioWorklet feature的音频管线,但同时也保留ScriptProcessorNode作为fallback兜底:
flowchart TB
classDef ui fill:#1e3a5f,stroke:#38bdf8,color:#e0f2fe
classDef mem fill:#422006,stroke:#fbbf24,color:#fef3c7
classDef proc fill:#4c0519,stroke:#fb7185,color:#fff1f2
classDef eng fill:#14532d,stroke:#4ade80,color:#dcfce7
classDef sink fill:#292524,stroke:#a8a29e,color:#fafaf9
subgraph Main["主线程"]
UI["键盘按钮<br/>按下 / 抬起"]:::ui
end
SAB["SharedArrayBuffer<br/>当前按下音符"]:::mem
subgraph Worklet["AudioWorklet 音频线程"]
direction TB
CB["process()"]:::proc
WASM["Vital WASM<br/>按状态渲染一帧"]:::eng
Out["写入 output"]:::eng
CB --> WASM --> Out
end
UI -->|写入| SAB
SAB -->|读取| CB
Out --> Dest["AudioContext.destination"]:::sink
Dest --> Spk["扬声器"]:::sink
对照前面的对比表格,把Vital这种重计算从UI主线程拆出去之后,移动端上的延迟和爆音一定能得到大大缓解,对吧?
然而,理想很丰满,现实很骨感。新版本在移动端的表现,反而比之前的版本还要差:使用同样的音色预设作为控制变量进行对比,移动端在引入AudioWorklet后的,虽然UI不再卡顿,但是音频的延迟、爆音等问题反而比之前ScriptProcessorNode的版本更加严重。
0x05 柳暗花明又一村
显然,方案2的性能回退并不符合我们的预期,将音频渲染放在独立线程运行理论上不应该导致性能回退。通过切换不同模型、清空上下文等方式,尝试让AI给出进一步的优化方案,但效果均不理想:
| 猜测原因 | 修复方法 | 结果 |
|---|---|---|
| 浏览器分配的音频缓冲太大 | 设置 latencyHint: 'interactive' |
无效 |
| Vital生成的音频采样率是48kHz,但是测试手机的采样率是44.1kHz,会触发重采样 | 对齐采样率 | 无效 |
| WASM 内存不够 动态分配内存或GC卡住 | 加大预分配内存大小 | 无效 |
在这个问题折腾了两天之后,我突然想起在开发者工具的Web Audio标签页的底部,有一个指标是“渲染能力”,在PC端,这个指标的数值在个位数以下徘徊,但是在移动端,只按下一个音符,这个指标就会达到60%以上,按下两个音符,这个指标就会达到100%。联想到之前在嵌入式硬件的开发经验,我又提出一个新的猜想,是不是Worklet的process()方法对于执行耗时有很严格的要求。
跟AI回顾了当前的AudioWorklet中Vital运行部分代码后,我发现,当前每次浏览器调用process()获取音频采样时,都需要等待Vital将最新的音频采样生成后才能返回。那么如果我们把Vital渲染的任务放到单独的Worker线程中运行,让AudioWorklet和WASM Worker之间通过SharedArrayBuffer实现的环形缓冲交换音频缓冲,是不是就能减少AudioWorklet的process()运行耗时了。
方案3: Web Worker 音频预渲染 + AudioWorklet
跟AI描述了这个新想法以后,它很快给出了实现:把Vital渲染挪到独立的Web Worker里,提前生成后续音频缓冲,不再依赖process()的音频回调触发,AudioWorklet的process()只负责从SharedArrayBuffer环形缓冲里取现成采样并返回。这样一来,音频回调不再被重计算拖死。
flowchart TB
classDef ui fill:#1e3a5f,stroke:#38bdf8,color:#e0f2fe
classDef mem fill:#422006,stroke:#fbbf24,color:#fef3c7
classDef worker fill:#164e63,stroke:#22d3ee,color:#ecfeff
classDef eng fill:#14532d,stroke:#4ade80,color:#dcfce7
classDef proc fill:#4c0519,stroke:#fb7185,color:#fff1f2
classDef sink fill:#292524,stroke:#a8a29e,color:#fafaf9
subgraph Main["主线程"]
UI["键盘按钮<br/>按下 / 抬起"]:::ui
end
NoteSAB["SharedArrayBuffer<br/>当前按下音符"]:::mem
subgraph Producer["Web Worker"]
direction TB
WASM["Vital WASM<br/>提前渲染"]:::eng
WriteRing["写入环形缓冲"]:::worker
WASM --> WriteRing
end
RingSAB["SharedArrayBuffer<br/>音频环形缓冲"]:::mem
subgraph Worklet["AudioWorklet 音频线程"]
CB["process()<br/>读出"]:::proc
Out["写入 output"]:::proc
CB --> Out
end
UI -->|写入| NoteSAB
NoteSAB -->|读取| WASM
WriteRing -->|写入| RingSAB
RingSAB -->|读取| CB
Out --> Dest["AudioContext.destination"]:::sink
Dest --> Spk["扬声器"]:::sink
功夫不负有心人,这次优化成功验证了我们之前的猜想,移动端的“渲染能力”指标从100%降到了30%左右,延迟、爆音等问题也得到了有效修复。
0x06 总结&回顾
在此次Vibe Coding尝试后,我尝试在网络上搜索关于“Web Worker SharedArrayBuffer AudioWorklet”的相关资料,意外地发现,其实早已有前人发现这一点,但相关内容都埋没在Github Discussion等互联网深处,如果是没有相关开发经验的人根本无法找到这些资料:
- AudioWorklet, SharedArrayBuffer, and Worker | GoogleChromeLabs Web Audio Samples
原文中明确提到将C/C++到音频应用带到Web平台的最佳实践是AudioWorklet + SharedArrayBuffer + Web Worker:This pattern is useful when bringing legacy audio application written in C/C++ into the web platform. - WebAudio/web-audio-api Discussion #2550
维护者 padenot 谈到把深度学习推理塞进 AudioWorklet 时也给了同样的方案:If it isn’t possible to guarantee those properties for your particular problem (synchronicity + consistent processing time), the solution is to send the real-time audio data to a Web Worker, and to perform the analysis there. The correct way to do this is to not use postMessage, but to use a wait-free single-producer single-consumer ring-buffer.
然而,在编写本文的同时,我再次尝试将0x00~0x04部分内容节选出来作为上下文,提供给GPT-5.6-sol、Claude-Opus-4.8、Claude-Opus-5、DeepSeek-v4-pro-beta、GLM-5.2等旗舰模型,要求提供解决方案。最后只有Claude-Opus-5提及了“在worklet内部做解耦渲染”,但给出的代码依然将Vital WASM运行在AudioWorklet内部,而不是单独的Web Worker线程。