基于浏览器的鼓机/节拍音序器是 Web Audio API 学习路径中训练价值很高的项目之一。它没有后端依赖,却同时涉及实时音频调度、声音合成、步进网格交互、视觉反馈和性能优化多个层面。很多开发者一接触到这类项目,首先想到的是“播放一段采样音频”,但真正把鼓机做成可用的工具,核心难点其实在时钟模型和交互响应上。下面从零开始实现一个可以直接在浏览器里运行的鼓点/节拍音序器,代码尽量保持最小闭环,但工程结构会按可扩展的方式来组织。
整个项目会完成三部分能力:用 Web Audio API 合成 Kick、Snare、Hihat 三种基础鼓声;用 lookahead 调度器驱动 16 步循环;提供可点击的步进网格和 BPM、Swing 控制。文章末尾会给出常见故障排查表和生产环境注意点,方便你在自己的项目中继续扩展。
1. 先理解Web鼓机/节拍音序器的三个技术底座
1.1 鼓机音序器到底在做什么
鼓机音序器通常由一条条横向的轨道组成,每条轨道对应一种鼓声,比如底鼓、军鼓、踩镲。纵向把一个小节平均切成若干份,最常用的是 16 份,也就是 16 分音符。用户在每个格子上决定“这个位置是否触发声音”,形成一个循环模式。播放引擎按固定 BPM 从第一格走到最后一格,遇到被激活的格子就触发对应声音。
这个机制听起来简单,但落地时有一个容易被忽略的问题:声音的触发不能依赖系统的 setInterval 或 setTimeout 精确计时。浏览器主线程上的定时器会受到页面渲染、事件回调、垃圾回收影响,在复杂页面里偏差可能达到几十毫秒甚至更多。对于旋律性音序器,几十毫秒的偏差已经能被耳朵明显察觉。因此鼓机项目首先要建立正确的音频时钟模型。
1.2 Web Audio API:为什么用合成音比音频文件更可控
Web 音序器要发声,有两种做法。第一种是加载采样文件,用 AudioBufferSourceNode 播放录音;第二种是用 OscillatorNode、噪声缓冲区和 GainNode 现场合成出鼓声。
采样方案音色真实,但文件加载、跨域、内存占用和波形编辑会增加复杂度。合成方案在代码层面更容易控制频率、时值和音量包络,也更容易做出参数化调整。对于学习 Web Audio API 的人来说,合成鼓声是更好的练习起点。
合成鼓声的核心思路是:每种鼓声本质上是一段能量快速衰减的音频信号。底鼓是一个从高到低快速扫频的低频振荡器;军鼓是噪声叠加一个带通滤波的共鸣声;踩镲是高通滤波后的短噪声。把这些声音拆成振荡器、噪声源和滤波器的组合,代码结构就非常清晰了。
1.3 时序模型:为什么不能用setInterval直接播放声音
初学者最自然的做法是:
setInterval(() => { playKick(); steps.forEach((active, index) => { if (active && index === currentStep) playSound(); }); }, intervalMs);这段代码的问题在于:setInterval 的回调时间不精确。浏览器在标签页切到后台时会降低定时器频率,主线程一旦被长任务占用,回调会堆积或延迟。更关键的是,声音播放用的是audioCtx.currentTime,这是一个独立于页面主线程的高精度音频时钟。正确做法是“提前安排”,也就是 lookahead scheduling:每隔几十毫秒检查一次未来 100 毫秒左右需要播放哪些声音,用audioCtx.currentTime计算精确的触发时间,把播放事件安排到时间轴上。
这样即使主线程在某个时刻发生了延迟,只要提前安排过,音频引擎依然会在正确的时间点发声。下面所有代码都会围绕这个模型展开。
2. 环境准备与最小项目骨架,先让浏览器出声
2.1 开发环境要求
这个项目不依赖构建工具,直接使用浏览器原生模块和 Web Audio API。建议环境如下:
| 项目 | 推荐配置 | 说明 |
|---|---|---|
| 浏览器 | Chrome / Edge / Firefox 最新版 | 各浏览器对 AudioContext 支持已基本一致 |
| 开发服务 | 本地静态服务器或 VS Code Live Server | file:// 协议下模块加载有限制 |
| Node.js | 可选,仅用于启动 http-server | 不安装也不影响阅读 |
| 编辑器 | VS Code 或任意代码编辑器 | 无强制要求 |
如果你的浏览器比较旧,需要关注AudioContext是否为webkitAudioContext。现代浏览器可以直接使用标准名称,但为了兼容可以加一行兜底。
2.2 项目结构与页面骨架
先建立如下目录结构:
drum-sequencer/ ├── index.html ├── styles.css └── app.js这是最简结构,适合独立运行。后面如果项目变大,可以考虑 Vite + TypeScript,但学习阶段先保持文件少,逻辑更容易跟踪。
index.html负责页面结构:一组 BPM 控制、播放按钮、清空按钮,以及一个用于展示 16 步网格的容器。网格先不写死,由 JavaScript 根据轨道数据动态生成。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Web Drum Sequencer</title> <link rel="stylesheet" href="styles.css" /> </head> <body> <div class="app"> <div class="toolbar"> <button id="playButton">Play</button> <label>BPM <input id="bpmSlider" type="range" min="60" max="180" value="90" /></label> <span id="bpmValue">90</span> <button id="clearButton">Clear</button> </div> <div id="stepGrid" class="step-grid"></div> </div> <script src="app.js"></script> </body> </html>注意播放按钮的类型是button,默认行为不会提交表单,避免刷新页面。BPM 使用 range 控件,虽然精度有限,但交互直观,适合手动调试。
2.3 初始化AudioContext并解决浏览器自动播放策略
浏览器不允许页面加载后直接播放音频,必须在用户手势事件中创建或恢复 AudioContext。这个限制是为了防止网页突然发声。所以初始化逻辑要放到按钮点击事件里。
let audioCtx = null; let masterGain = null; async function initAudio() { if (!audioCtx) { const Context = window.AudioContext || window.webkitAudioContext; audioCtx = new Context(); masterGain = audioCtx.createGain(); masterGain.gain.value = 0.8; masterGain.connect(audioCtx.destination); } if (audioCtx.state === 'suspended') { await audioCtx.resume(); } }这里有两个关键点。第一,audioCtx全局只创建一次,避免重复创建多个音频上下文。第二,masterGain作为主音量总线,所有鼓声都连接到这个节点上,后续加压缩器、限制器也更方便。
在点击播放按钮时,先调用await initAudio(),再启动调度器。这样能保证用户手势触发时,音频上下文处于运行状态。
2.4 主音量与音频总线
系统中的音频信号流大概是:
振荡器/噪声源 -> 滤波/增益节点 -> masterGain -> audioCtx.destinationdestination是浏览器音频输出设备。中间加一层masterGain,是为了统一控制总音量,并为后续加效果器留下插入点。比如你可以在这里插入一个DynamicsCompressorNode,防止多个鼓声同时触发时声音过载。
在合成鼓声的代码里,每个声音内部有自己的音量包络节点,最终连接到masterGain。这样不同声音之间不会互相修改对方的增益,音频总线保持干净。
3. 用Web Audio API合成三款基础鼓声
3.1 Kick:频率包络才是低鼓的灵魂
底鼓的听感来自频率快速下落和振幅快速衰减。实现上使用一个OscillatorNode,波形选正弦波,频率从 150Hz 左右指数下降到 50Hz 左右。这里的指数下降很重要,因为人耳对频率的感知近似对数关系,指数变化听感更自然。
function playKick(time) { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = 'sine'; osc.frequency.setValueAtTime(150, time); osc.frequency.exponentialRampToValueAtTime(50, time + 0.1); gain.gain.setValueAtTime(1, time); gain.gain.exponentialRampToValueAtTime(0.001, time + 0.2); osc.connect(gain); gain.connect(masterGain); osc.start(time); osc.stop(time + 0.22); }exponentialRampToValueAtTime不能从 0 开始,所以起始值要设为正数。停止时间比包络结束时间略长,保证尾音不会被硬生生切断。
time参数来自调度器,不是立即播放。所有声音函数都要接收一个time参数,并在该时间点安排声音事件。这一点是 Web Audio 程序与普通播放器最大的区别。
3.2 Snare:噪声源配合带通滤波
军鼓的听感比底鼓复杂,通常由“噪声爆破”和一个带通频率的“鼓身共鸣”组成。噪声部分使用一个预先创建的AudioBuffer,填充随机数。共鸣部分可以用一个振荡器,但为了减小代码量,这里主要用滤波噪声实现。
function createNoiseBuffer() { const length = audioCtx.sampleRate * 1; const buffer = audioCtx.createBuffer(1, length, audioCtx.sampleRate); const data = buffer.getChannelData(0); for (let i = 0; i < length; i++) { data[i] = Math.random() * 2 - 1; } return buffer; }军鼓播放函数:
function playSnare(time) { const noise = audioCtx.createBufferSource(); noise.buffer = noiseBuffer; noise.loop = true; const filter = audioCtx.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.setValueAtTime(1800, time); filter.frequency.exponentialRampToValueAtTime(800, time + 0.08); const gain = audioCtx.createGain(); gain.gain.setValueAtTime(0.7, time); gain.gain.exponentialRampToValueAtTime(0.001, time + 0.12); noise.connect(filter); filter.connect(gain); gain.connect(masterGain); noise.start(time); noise.stop(time + 0.15); }noiseBuffer是全局创建一次还是每次都不一样,影响音色一致性。如果每次在函数里调用Math.random()生成随机噪声,军鼓会带有不稳定的沙沙感。实际项目中创建一次噪声 Buffer,播放同一个 Buffer 会更稳定。
3.3 Hihat:高通滤波与短时噪声
踩镲是高频噪声。实现上比军鼓更简单,噪声源加高通滤波器,包络时间更短。
function playHihat(time) { const noise = audioCtx.createBufferSource(); noise.buffer = noiseBuffer; noise.loop = true; const filter = audioCtx.createBiquadFilter(); filter.type = 'highpass'; filter.frequency.value = 7000; const gain = audioCtx.createGain(); gain.gain.setValueAtTime(0.5, time); gain.gain.exponentialRampToValueAtTime(0.001, time + 0.04); noise.connect(filter); filter.connect(gain); gain.connect(masterGain); noise.start(time); noise.stop(time + 0.05); }这里最核心的参数是0.04秒的衰减时间。踩镲的延音越短,节奏感越清晰。可以加一个控件让用户调整这个值,但要注意过小的值可能导致 click 声,因为振幅包络从 0.5 到 0.001 的下降过程太短。
3.4 为什么包络要使用指数变化而非直接赋值
如果代码写成gain.gain.value = 0.001,声音会瞬间从 1 掉到 0.001,产生可听见的爆破声。正确做法是使用setValueAtTime和exponentialRampToValueAtTime,让增益在一段时间内平滑变化。
在 Web Audio API 中,setValueAtTime相当于在时间轴上放一个锚点,exponentialRampToValueAtTime从锚点值开始指数过渡到目标值。指数曲线更接近真实乐器的自然衰减,而且不会出现线性变化里那种“突然结束”的听感。
同样的道理也可以应用到滤波器频率、振荡器频率上。底鼓要做出“下滑”效果,用的就是频率包络。理解包络是合成鼓声的重要分水岭。
4. 实现lookahead步进调度器与16步网格
4.1 音序状态与步进数据结构
音序器的核心数据结构是 track 数组。每条 track 包含名称、16 个 0/1 状态、以及对应的声音播放函数。
const stepsPerBar = 16; const tracks = [ { name: 'Kick', steps: [1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0], sound: playKick }, { name: 'Snare', steps: [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0], sound: playSnare }, { name: 'Hihat', steps: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], sound: playHihat } ]; let currentStep = 0; let nextNoteTime = 0; let timerId = null; let isPlaying = false;播放状态只用三个变量:当前步进序号、下一个音符的绝对时间、定时器 ID。不要在播放过程中重建整个 audioCtx。
4.2 lookahead调度器的核心循环
调度器每隔 25ms 运行一次。每次运行时,不断把未来 0.1 秒内需要播放的事件安排进音频时钟。
const lookaheadMs = 25; const scheduleAheadTime = 0.1; function startSequencer() { if (isPlaying) return; isPlaying = true; const secondsPerBeat = 60 / bpm; nextNoteTime = audioCtx.currentTime + 0.05; currentStep = 0; timerId = setInterval(scheduler, lookaheadMs); } function scheduler() { while (nextNoteTime < audioCtx.currentTime + scheduleAheadTime) { scheduleStep(currentStep, nextNoteTime); advanceStep(); } } function scheduleStep(step, time) { tracks.forEach((track) => { if (track.steps[step]) { track.sound(time); } }); } function advanceStep() { const secondsPerBeat = 60 / bpm; const secondsPerStep = secondsPerBeat / 4; nextNoteTime += secondsPerStep; currentStep = (currentStep + 1) % stepsPerBar; }这段代码的关键在于while循环。假设setInterval延迟了 30ms,但上一轮已经安排了未来 0.1 秒内的事件,那么音频不会断。即使当前回调发生延迟,audioCtx.currentTime也不会后退,已经在时间轴上安排好的声音会照常播放。
注意secondsPerStep用secondsPerBeat / 4表示一个 16 分音符。如果 BPM 是 90,每拍 0.667 秒,每个 16 分音符约 0.167 秒。这个时间单位在调度器内部累积,不能用Date.now()来算节奏,否则会受到系统时钟和主线程忙闲的影响。
4.3 步进网格的DOM渲染与交互
网格 DOM 可以由 JS 动态生成。每一列对应一个 step,每一行对应一个 track。为了保证点击顺序正确,外层容器按 row 排列,每个 row 是一行按钮。
const stepGrid = document.getElementById('stepGrid'); tracks.forEach((track, trackIndex) => { const row = document.createElement('div'); row.className = 'step-row'; track.steps.forEach((active, stepIndex) => { const cell = document.createElement('button'); cell.dataset.track = trackIndex; cell.dataset.step = stepIndex; cell.classList.toggle('active', active === 1); cell.addEventListener('click', () => { track.steps[stepIndex] = track.steps[stepIndex] ? 0 : 1; cell.classList.toggle('active'); }); row.appendChild(cell); }); stepGrid.appendChild(row); });注意这样实现的数据流是单向的:按钮负责更新数组,数组是权威数据源。调度器读数组决定是否播放声音,视觉状态由数组变化驱动。不要在点击按钮时直接播放声音,否则会出现“双击调音色”和“播放时编辑冲突”的问题。
当前播放位置的可视化,可以在scheduler中更新一个currentStep指示器。最简单的方法是在每次调度后把上一列的 active 指示移除,给当前列添加playing类。由于视觉更新不需要音频级精度,直接在scheduler回调里做是可以接受的。
4.4 BPM、Swing和样本输出的参数设计
BPM 虽然可以在调度器运行时动态读取bpmSlider.value,但要注意音符间隔是不断累积的。如果用户把 BPM 从 90 拉高到 150,已经排队的声音不会立即改变,必须等待下一个音符时间更新。这种处理在实时演出中可以接受,但如果你希望 BPM 变化立即生效,需要停机重置nextNoteTime和currentStep。
Swing 是鼓机中常见的节奏摆动效果。实现上可以把偶数步(或者每两个步进的第二半)延后一定比例,比如 60% 的位置。简单做法是每次advanceStep时根据 stepIndex 判断是否加上偏移量:
function advanceStep() { const secondsPerBeat = 60 / bpm; const secondsPerStep = secondsPerBeat / 4; const swingOffset = currentStep % 2 === 1 ? secondsPerStep * swingAmount : 0; nextNoteTime += secondsPerStep + swingOffset; currentStep = (currentStep + 1) % stepsPerBar; }swingAmount范围通常取 -0.1 到 0.1,表示最长偏移不超过一个 16 分音符的 10%。这个功能会让音序器更接近真实鼓机的操作习惯。
BPM、Swing 和音量都属于“实时可调参数”。在实现时,最好把所有参数集中到配置对象里,统一从界面读取,避免散落在不同函数中。
5. 运行验证、性能观察与生产环境差异
5.1 启动本地服务并完成基本验证
在项目目录下启动一个本地静态服务:
npx http-server .浏览器打开http://localhost:8080,点击 Play 按钮。第一次点击时必须能听到声音,同时 16 步网格中应该能看到当前步进的高亮移动。
验证清单:
| 验证点 | 操作 | 预期结果 |
|---|---|---|
| 首次点击 | 点击 Play | 立即听到底鼓、军鼓、踩镲循环 |
| 音量控制 | 调整masterGain.gain.value | 音量平滑变化 |
| BPM 调节 | 拖动 BPM 滑杆 | 节奏快慢变化,无爆音 |
| 步进开关 | 点击任一格 | 对应声音出现或消失 |
| 暂停/恢复 | 再次点击 Play | 从当前步继续播放,不重头开始 |
这里暂停按钮如果复用 Play 按钮,需要注意状态机。推荐使用独立状态:stopSequencer()时将isPlaying置 false,清除timerId。
5.2 用时间戳日志验证调度偏差
音频调度是否准确,可以在scheduleStep中记录当前audioCtx.currentTime和nextNoteTime的差值:
function scheduleStep(step, time) { const drift = Math.abs(time - audioCtx.currentTime); if (drift > 0.005) { console.warn('Schedule drift:', drift, 'step:', step); } // 实际触发声音 }这个日志只能说明当前安排时间与音频时钟的偏差。如果偏差小于 5ms,基本正常。正常情况下,lookahead 可以保证偏差很小。如果经常出现大于 50ms 的情况,说明定时器被严重阻塞或scheduleAheadTime设得过小。
5.3 性能观察与移动端注意点
调度器每 25ms 运行一次,也就是每秒约 40 次。事件处理本身很轻,但如果在scheduler里做了太多 DOM 操作,比如每个 step 都创建新 DOM 节点,页面会明显卡顿。
移动端浏览器对 AudioContext 的限制更严格。除了要求用户手势触发外,部分浏览器在锁屏或切换到后台时会暂停音频。即使代码正确,也建议在页面visibilitychange事件中暂停调度器,避免回来时音符错乱。
document.addEventListener('visibilitychange', () => { if (document.hidden && isPlaying) { stopSequencer(); } });从生产环境角度看,还需要考虑音频上下文被系统打断的情况,比如来电。可以监听interrupted事件,但该事件在不同浏览器中支持度不一,因此实际项目要结合平台行为做降级处理。
5.4 学习环境与生产环境的差异
学习阶段使用原生 JS 和单 HTML 文件,便于理解。生产级音序器则需要考虑:
| 关注点 | 学习环境做法 | 生产环境建议 |
|---|---|---|
| 模块组织 | 一个app.js | 使用 ES Module 拆分 Scheduler、Synth、UI |
| 状态管理 | 全局变量 | 可观察对象或状态库,避免 UI 与状态不同步 |
| 音量保护 | 固定 0.8 | 加入压缩器,控制峰值 |
| 移动端体验 | 桌面优先 | 调整触控事件、防误触、响应式布局 |
| 离线能力 | 无 | 考虑 PWA,缓存静态资源 |
| 测试 | 手动验证 | 对调度器和步进状态写自动化测试 |
如果计划做成在线工具,建议把 scheduler 和 synth 拆成独立模块。调度器只负责发送“什么时间播放哪个声音”的事件,合成器只负责生成声音,UI 只负责读写状态。这样无论是接 MIDI 输入还是扩展采样器,都不必重写底层。
6. 常见故障排查:没声音、爆音、节奏漂移
6.1 点击播放后没有声音
在开发这类项目时,没声音是最常见的问题。先按以下顺序检查:
- 是否调用了
initAudio()并完成resume()。如果audioCtx.state === 'suspended',所有声音都不会输出。 - 是否在用户手势事件中处理音频。如果不是在点击事件里创建
AudioContext,浏览器会拒绝播放。 - 是否设置了音量为零。
masterGain.gain.value = 0不会报错,但也不会有声音。 - 是否连接了
masterGain到audioCtx.destination。缺失连接是新手常见疏漏。 - 浏览器控制台是否有报错。比如
AudioContext was not allowed to start会自动提示原因。
调试时可以添加状态显示:
console.log(audioCtx.state, audioCtx.currentTime);如果 state 是suspended,把resume()放在 Play 按钮回调的最前面。
6.2 声音出现爆音或失真
爆音主要来源于增益瞬间跳变。代码里如果直接用gain.gain.value = 1然后立刻用gain.gain.value = 0,会产生直流跳变。应该全部使用setValueAtTime加exponentialRampToValueAtTime或linearRampToValueAtTime。
另一个常见原因是多个鼓声同时触发时,所有声音叠加超过 0dB。比如默认 16 分音符的踩镲加上底鼓、军鼓同时触发,峰值可能非常高。解决方案是加入DynamicsCompressorNode:
const compressor = audioCtx.createDynamicsCompressor(); compressor.threshold.value = -12; compressor.knee.value = 20; compressor.ratio.value = 12; compressor.attack.value = 0.003; compressor.release.value = 0.25; masterGain.connect(compressor); compressor.connect(audioCtx.destination);压缩器不能替代正常的音量包络,它只是防止瞬时峰值过载。两者配合使用,声音会更稳定。
6.3 节奏越来越不跟手
如果 BPM 看起来正确,但一段时间后节奏越来越慢,或者某个声音明显偏移,问题通常出在时间累积方式上。
错误写法是在scheduler中每次用Date.now()计算间隔:
// 错误示例 setInterval(() => { playKick(); }, 60000 / bpm / 4);正确做法是维护一个绝对时间nextNoteTime,每次advanceStep时累加secondsPerStep。这样即使某一次回调延迟,也不会改变后续音符的绝对时间。如果你在scheduleStep中观察到 drift 持续增大,还可以把scheduleAheadTime调大一些,比如从 0.1 调到 0.2。
6.4 排查顺序速查表
| 现象 | 检查项 | 处理建议 |
|---|---|---|
| 没有声音 | AudioContext 状态 | 在用户手势中调用resume() |
| 没有声音 | 节点连接 | 检查gain.connect(masterGain)和masterGain.connect(destination) |
| 有爆音 | 包络是否平滑 | 用setValueAtTime+exponentialRampToValueAtTime |
| 有爆音 | 峰值过高 | 增加压缩器或降低音量 |
| 节奏漂移 | 是否使用audioCtx.currentTime | 改用 lookahead 调度 |
| 后台回来错乱 | 定时器是否被抑制 | 监听visibilitychange停止/恢复 |
排查时不要凭感觉猜,先在关键位置加日志,确认音频上下文状态、连接路径和调度时间三个点,再逐步缩小范围。
7. 最佳实践、扩展方向与练习建议
7.1 Web音序器最佳实践清单
这个项目用到的思路可以作为以后做浏览器音乐应用的基础清单:
- 音频上下文全局只创建一次,不要每次播放都
new AudioContext()。 - 所有声音播放函数都接收
time参数,不要在函数内部用now()获取时间。 - 声音调度必须有前瞻量,不能依赖
setInterval的精确性。 - 音量包络避免数值突变。
- 噪声 Buffer 创建一次后复用,不要每次播放都生成。
- 将所有可调参数集中在配置对象中,方便 UI 绑定。
- 播放状态和步进数据独立维护,UI 只做展示和交互。
- 在生产环境加入压缩器和错误日志。
这些清单解决的是“程序能跑,但能不能长期用”的问题。如果只是课堂作业,可能不需要全部做到;但如果想把项目发布成真正可用的工具,这些就是底线。
7.2 扩展方向:采样器、多通道、导出与MIDI
当前实现是合成鼓声。更真实的鼓机需要支持采样器:加载外部 WAV 文件,把文件内容变成AudioBuffer,之后用AudioBufferSourceNode播放。这个改造不会改变调度器逻辑,只需把track.sound替换成采样播放函数。
多通道指每个轨道有独立的音量和效果器发送量。这需要把masterGain拆成每个 track 一个GainNode,再统一连接到 master。以后加混响、延时也可以挂到通道上。
导出音频是音序器工具的一个重要功能。可以使用OfflineAudioContext在无播放状态下快速渲染整个模式的音频,然后通过MediaRecorder或AudioBuffer转 WAV 导出。这个过程比较适合熟悉 Web Audio 后再深入。
MIDI 支持可以面向外部键盘或控制器的输入。Web MIDI API 允许浏览器访问 MIDI 设备,但需要用户授权。扩展时可以在调度器之外增加一个事件监听层,把 MIDI Note On/Off 映射到步进写入或声音触发。
7.3 对初学者的练习路径
如果第一次接触 Web Audio,不建议直接写完整音序器。可以按四步来练:
- 先写一个按钮点击后播放单个底鼓的页面。
- 加入第二个声音,把按钮改成键盘触发。
- 做固定间隔的循环播放,体会 setInterval 的不足。
- 依照本文思路实现 lookahead 调度器。
每一步都验证“声音有没有变化”“节奏是否稳定”“用户操作是否及时反馈”。练完这套路径,再去看采样器、效果器、可视化波形,会有更清晰的方向。
鼓机/节拍音序器项目不只是一个前端玩具,它把浏览器音频引擎、实时系统调度和交互设计压缩在一个可以独立运行的小项目中。真正理解 lookahead scheduling 和 Web Audio 时间模型之后,再去开发更复杂的音乐编辑器或实时演出工具,会顺畅很多。