
1. 从零搭建钢琴模拟器为什么我放弃了采样音频改用 Web Audio API 振荡器合成先说结论如果你打算在网页里做一个能弹的钢琴最直觉的做法是找一套 88 键的 WAV 采样包按一个键播一个音频文件。我最早也是这么干的结果页面加载 8MB 起步弱网下白屏好几秒多按几个键还会出现明显的爆音和卡顿。后来我把整套方案推倒重来用浏览器原生的 Web Audio API 振荡器加 ADSR 包络合成音色整个页面零外部音频依赖加载瞬间完成和弦演奏也干净利落。这篇内容适合三类人一是想入门 Web Audio API 但不知道从哪下手的前端二是做过网页乐器但被采样加载和多音并发坑过的开发者三是用 Cursor 这类 AI 编辑器写代码、希望把模型调用统一到一个 Key 上的同学。我会把振荡器参数、ADSR 包络曲线、键位映射表全部给成可复制的配置并且说明怎么把 Cursor 的 Base URL 改到 TaoToken让代码生成和调试建议走同一个入口。核心检索词先摆出来Web Audio API 钢琴模拟器、键盘演奏、ADSR 包络、振荡器合成。这几个词贯穿全文你照着做就能得到一个能弹的网页钢琴。先讲清楚原理不然后面调参数会懵。真实钢琴发声是琴槌敲击钢弦弦振动产生基频和泛音然后通过音板共鸣放大松键后声音不会立刻消失而是有一段自然衰减的余音。采样方案是把这个过程录下来直接播放合成方案则是用数学方式重新生成这个过程。Web Audio API 里的 OscillatorNode 负责产生基础波形GainNode 负责控制音量随时间的变化也就是 ADSR 包络。把这两者串起来再挂一个动态压缩器防止多音叠加爆音就能模拟出相当接近钢琴的听感。我实测下来三角波triangle比正弦波更适合做钢琴音色因为它带一点奇次谐波听起来更温润厚实接近木质钢琴的质感。正弦波太纯净像电子琴锯齿波太尖锐像合成器 lead。三角波是甜点区。整个合成链路是这样的OscillatorNode → GainNodeADSR 包络→ DynamicsCompressorNode防爆音→ 物理输出。每个音符独立创建一套振荡器和增益节点松键后延迟释放内存。这个结构决定了后面所有代码的写法。2. Cursor 接入 TaoToken 统一 KeyBase URL 与模型配置实操在动手写钢琴代码之前先把 Cursor 的模型调用入口配好。这样你在写 Web Audio 逻辑时可以让模型帮你补全振荡器参数、检查 ADSR 时间调度而不用在多个平台之间来回切 Key。TaoToken 是一个统一模型调用入口官网是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。它的作用是让你用一个 Key 调用多种模型Cursor 里改一下 Base URL 就能接上。具体操作分三步。第一步去控制台创建 API Key地址是 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 页面生成一个 Key复制保存。第二步打开 Cursor 设置找到 Models 或 OpenAI API Key 配置区把 Base URL 改成https://taotoken.net/api把 Key 填进去。第三步在模型列表里选一个你常用的模型 ID比如 claude-sonnet 系列或 gpt 系列保存后测试一下对话是否正常。如果你用的是 Claude Code 或 Cline 这类工具配置逻辑类似核心三件套是 Base URL、Key、Model ID。以 Cline 的 MCP 配置为例settings 片段大概长这样{ mcpServers: { taotoken: { command: npx, args: [-y, taotoken/mcp-server], env: { TAOTOKEN_BASE_URL: https://taotoken.net/api, TAOTOKEN_API_KEY: sk-你的Key, TAOTOKEN_MODEL: claude-sonnet-4-20250514 } } } }注意 Base URL 后面不要多加/v1TaoToken 的 API 路径已经处理好了。填错会出现 404 或 local proxy failed。配好之后你在 Cursor 里让模型生成 Web Audio 代码请求就走 TaoToken 了。这一步的意义在于写钢琴模拟器时你会反复让模型帮你调 ADSR 参数、排查音频节点连接问题统一 Key 之后不用每次换工具都重新配。模型对话入口在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 遇到配置问题可以先查文档。3. 可复制配置振荡器 ADSR 参数 键位映射表现在进入核心部分。新建一个piano.html文件把下面的结构写进去。我先把关键配置单独拎出来方便你对照修改。音符频率映射表C4 到 C5 一个八度const NOTE_FREQS { C4: 261.63, C#4: 277.18, D4: 293.66, D#4: 311.13, E4: 329.63, F4: 349.23, F#4: 369.99, G4: 392.00, G#4: 415.30, A4: 440.00, A#4: 466.16, B4: 493.88, C5: 523.25 };键盘字母到音符的绑定const KEY_TO_NOTE { a: C4, w: C#4, s: D4, e: D#4, d: E4, f: F4, t: F#4, g: G4, y: G#4, h: A4, u: A#4, j: B4, k: C5 };ADSR 包络参数这是决定音色像不像钢琴的关键const ADSR { attackTime: 0.01, // 起音 10ms模拟琴槌瞬间敲击 decayTime: 0.15, // 衰减 150ms音量降到维持点 sustainLevel: 0.5, // 延音电平 50%按住键时保持 releaseTime: 0.8 // 释音 800ms松键后余音渐弱 };振荡器类型选triangle频率用osc.frequency.value frequency直接赋值。增益节点用setTargetAtTime做指数衰减比线性 ramp 更接近物理共鸣。初始化音频上下文时一定要用延迟单例模式全局只创建一个 AudioContextlet audioCtx null; let compressor null; function initAudio() { if (audioCtx) return; const AudioContextClass window.AudioContext || window.webkitAudioContext; audioCtx new AudioContextClass(); compressor audioCtx.createDynamicsCompressor(); compressor.connect(audioCtx.destination); }发声函数里创建振荡器和增益节点串联到压缩器function playNote(noteName) { if (activeNodes[noteName]) return; initAudio(); if (audioCtx.state suspended) audioCtx.resume(); const frequency NOTE_FREQS[noteName]; if (!frequency) return; const osc audioCtx.createOscillator(); osc.type triangle; osc.frequency.value frequency; const gainNode audioCtx.createGain(); osc.connect(gainNode); gainNode.connect(compressor); const now audioCtx.currentTime; gainNode.gain.setValueAtTime(0, now); gainNode.gain.linearRampToValueAtTime(1.0, now ADSR.attackTime); gainNode.gain.setTargetAtTime(ADSR.sustainLevel, now ADSR.attackTime, ADSR.decayTime); osc.start(now); activeNodes[noteName] { oscillator: osc, gainNode: gainNode }; }松键时触发 Release 阶段并在余音结束后断开节点释放内存function stopNote(noteName) { const node activeNodes[noteName]; if (!node) return; const now audioCtx.currentTime; const { gainNode, oscillator } node; gainNode.gain.cancelScheduledValues(now); gainNode.gain.setValueAtTime(gainNode.gain.value, now); gainNode.gain.setTargetAtTime(0, now, ADSR.releaseTime / 4); oscillator.stop(now ADSR.releaseTime); setTimeout(() { if (activeNodes[noteName] activeNodes[noteName].oscillator oscillator) { oscillator.disconnect(); gainNode.disconnect(); delete activeNodes[noteName]; } }, ADSR.releaseTime * 1000 100); }键盘事件绑定要注意e.repeat判断防止长按重复触发window.addEventListener(keydown, (e) { if (e.repeat) return; const note KEY_TO_NOTE[e.key.toLowerCase()]; if (note) playNote(note); }); window.addEventListener(keyup, (e) { const note KEY_TO_NOTE[e.key.toLowerCase()]; if (note) stopNote(note); });这套配置直接复制就能跑。HTML 和 CSS 部分负责画出白键黑键、绑定 data-note 属性样式上白键用渐变背景、黑键绝对定位浮在上层按下时加.active类改变高度和阴影视觉反馈就出来了。4. 验证请求与成功结果逐音检查音高和包络代码写完后打开 Chrome按 F12 看 Console。点击中央 C 键控制台应该输出类似合成器击键 音符: C4 | 物理频率: 261.63Hz的日志。如果没输出说明事件没绑上或者频率表里没这个音符。逐音验证音高从 C4 到 C5 依次点击每个白键对照频率表听音高是否递增。C4 是 261.63HzC5 是 523.25Hz正好翻倍这是八度关系。如果某个键音高明显不对检查 NOTE_FREQS 里对应的数值。验证 ADSR 包络按住一个键不放声音应该在 10ms 内快速起来然后 150ms 内稍微降一点音量之后保持稳定。松开键的瞬间声音不是立刻断掉而是有大约 0.8 秒的余音渐弱。如果松键后声音戛然而止说明 Release 阶段没生效检查setTargetAtTime的调用。验证和弦同时按住 A、D、G 三个键对应 C4、E4、G4这是 C 大调和弦。控制台会输出三条击键日志声音应该是三个音叠加的饱满和弦没有爆音。如果听到沙沙的电流声说明压缩器没起作用检查compressor.connect(audioCtx.destination)是否执行。验证内存释放快速连续弹奏多个音符然后停止等 1 秒左右控制台应该输出内存清理 释放音源节点: C4之类的日志。如果一直不输出说明 setTimeout 里的清理逻辑没触发长时间演奏会累积僵尸节点。我实测下来这套方案在 Chrome 和 Edge 上表现一致Safari 需要用户先交互一次才能启动 AudioContext这是浏览器的自动播放策略不是 bug。第一次点击任意键时audioCtx.resume()会激活上下文之后就正常了。5. 常见报错排查401、local proxy failed、reading choices、OAuth配置 Cursor 接 TaoToken 时最容易遇到几类报错我逐个说清楚。401 UnauthorizedKey 填错了或者没填。检查 Cursor 设置里的 API Key 是否完整复制有没有多余空格。TaoToken 的 Key 以sk-开头去控制台重新生成一个再试。local proxy failedBase URL 写错了。正确写法是https://taotoken.net/api不要加/v1不要加尾部斜杠。如果你之前配过其他中转地址先清空再填。reading choices 报错模型返回格式不匹配。通常是 Model ID 填错了或者该模型不支持当前调用方式。去模型对话页面确认可用的模型 ID填到 Cursor 的模型配置里。OAuth 相关报错如果你用的是 Claude Code 或 Codex 这类需要登录的工具OAuth 流程走的是官方账号体系和 API Key 是两套东西。用 TaoToken 统一 Key 时应该走 API Key 模式不要走 OAuth 登录。Codex 的auth.json里配置 Base URL 和 Key不要混用登录态。音频相关的报错AudioContext was not allowed to start说明用户还没交互就尝试播放把initAudio()放到第一次点击或按键事件里。oscillator.stop报错说明节点已经停止过检查是否重复调用 stopNote。多音爆音如果压缩器加了还有爆音检查每个音符的 gainNode 初始值是否设为 0Attack 阶段是否从 0 开始 ramp。如果直接从 1.0 开始会有咔哒声。排查顺序建议先看 Console 报错再看 Network 请求是否到达 TaoToken最后检查音频节点连接。大部分问题出在配置层代码逻辑本身不复杂。6. 继续扩展与统一入口这套钢琴模拟器跑通之后你可以继续加功能用osc.detune做轻微失谐让音色更厚加一个低通滤波器模拟音板共鸣或者用setValueCurveAtTime做更精细的包络曲线。如果想做自动演奏把音符序列和时值写成数组用setTimeout或audioCtx.currentTime调度即可。长期写代码的话Coding Plan 入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 适合需要频繁调用模型辅助开发的场景。API Keys 管理在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。Claude Code 相关配置参考 https://taotoken.net/claudecode-anthropic?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecode-anthropicutm_campaignrewrite 。最后留一个我踩过的坑ADSR 的 releaseTime 不要设太长超过 1.5 秒会让快速演奏时音符糊在一起听起来像踩了延音踏板不放。0.8 秒是我试出来比较自然的数值你可以根据曲风微调。