ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用Web Audio API从零构建网页版鼓机音序器:核心原理与完整实现

用Web Audio API从零构建网页版鼓机音序器:核心原理与完整实现 一个网页版鼓机音序器听起来像是“Show HN”上的一个小玩具但真要从零实现会同时涉及 Web Audio 音频合成、精确定时调度、UI 状态同步这几块硬骨头。这篇文章会把整个项目拆开讲清楚并给出一个可以直接运行的 16 步鼓机示例适合想入门 Web Audio、或者想在浏览器里做音乐工具的开发者参考。文中会包含四个部分先解释鼓机和网页音序器的基本概念再讲音频合成与节拍调度的核心原理然后给出完整的前端项目代码最后整理常见问题和工程建议。代码可以在本地直接跑起来你也可以基于它继续扩展成更完整的音序器。1. 背景为什么需要一台网页版鼓机音序器1.1 什么是 Drum/Beat SequencerDrum Sequencer中文常叫鼓机音序器是一种按时间步进Step编排鼓组节奏的工具。传统硬件鼓机有一个个按键每按一次就代表某个时间点上触发一次打击乐音色多个打击乐音轨叠加起来就形成一段循环的节奏 pattern。Beat Sequencer 侧重点通常在“节拍编排”上常见的操作方式是把一段音乐按时间切成若干步通常是 16 步或 32 步。每行代表一种乐器例如底鼓 Kick、军鼓 Snare、闭镲 Hi-Hat。每个格子代表该乐器在对应时间点是否发声。播放时按固定 BPM 循环扫描这些格子触发相应的音色。这种格子式交互在硬件设备中已经被验证了几十年搬到浏览器里同样直观。用户不需要学过音乐制作也能很快理解“点亮格子 让声音在这个时刻响一下”。1.2 Web-based 方案的优势网页版鼓机和本地音频软件相比优势不仅在于“免安装”。第一入口成本极低。打开浏览器输入 URL 就能使用很适合做在线音色分享、节奏模板交换或者嵌入到教学页面中。这也是很多 Show HN 项目选择 Web 端的原因。第二技术栈统一。一个鼓机项目从界面到音频调度全部可以用 HTML/CSS/JavaScript 完成。不需要处理不同平台的插件格式也没有庞大的安装包。第三现代浏览器已经提供了足够强大的音频能力。Web Audio API 可以完成振荡器、噪声合成、滤波器、增益包络、动态压缩等操作足以合成出很不错的鼓音色也可以播放采样文件。配合更高级的 Audio Worklet还能在音频渲染线程里做低延迟处理。所以Web 鼓机并不是一个“性能受限的玩具”而是一个真正可以做成产品的方向。1.3 技术选型原生 Web Audio API 还是库实现网页版音序器通常有三类选择完全使用原生 Web Audio API。使用 Tone.js 这类 Web Audio 工具库。使用音频采样文件 前端框架。对于初学或想理解底层原理的人来说原生方式最合适。它不会帮你隐藏音频图构建、时间调度、节点连接这些核心概念。等你能用原生方式实现一个清晰、稳定的音序器之后再切换到 Tone.js 就会非常快因为 Tone.js 本质上是把这些问题封装得更高级。本文示例采用原生 Web Audio API 实现代码量不大但五脏俱全。2. 环境准备与项目结构2.1 运行环境说明本文示例是一个纯前端项目不依赖任何构建工具也不需要安装第三方包。运行条件如下现代浏览器推荐 Chrome、Edge 或 Firefox。建议通过本地 HTTP 服务访问例如python3 -m http.server或 VS Code 的 Live Server。不建议直接双击index.html以file://协议运行因为部分浏览器在非安全上下文或file://下对 Web Audio 支持不完整。Node.js 不是必须的但如果想后续做自动构建、测试或接入 MIDI可以提前装一个 LTS 版本。版本方面不需要指定得太死。Web Audio API 在现代浏览器中已经稳定多年代码中兼容了webkitAudioContext前缀主要是照顾旧版 iOS Safari。2.2 项目目录结构项目采用传统的 HTML CSS JS 结构不使用模块语法方便本地直接调试。drum-sequencer/ ├── index.html ├── css/ │ └── style.css └── js/ ├── audioEngine.js ├── sequencer.js ├── ui.js └── main.js文件职责文件职责index.html页面骨架、工具栏、播放按钮、BPM 滑块css/style.css网格布局、格子样式、播放高亮效果js/audioEngine.jsAudioContext 管理、鼓音色合成js/sequencer.js音序调度、BPM 计算、播放状态管理js/ui.js渲染格子、绑定点击事件、更新播放高亮js/main.js初始化数据、串联音序器和界面2.3 基础概念速览为了后面阅读代码不迷糊先认识三个核心对象。AudioContext音频上下文是所有音频操作的入口。它维护自己的高精度时钟所有声音事件都基于AudioContext.currentTime来安排。AudioNode音频节点例如振荡器、滤波器、增益节点。多个节点通过connect方法连接成一条音频图最终连到AudioDestinationNode也就是扬声器。Scheduler音序调度器。它决定“什么时候让某个声音响起来”。这里有一个容易踩坑的点不能简单用 JavaScript 的setInterval直接触发音频因为setInterval的精度受主线程任务影响会让节奏越跑越偏。3. 核心原理拆解3.1 Web Audio API 的音频图模型Web Audio 编程的基本思路是“搭图”而不是“命令式播放”。例如合成一个最简单的正弦波声音const audioCtx new AudioContext(); const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.type sine; osc.frequency.value 440; gain.gain.value 0.3; osc.connect(gain); gain.connect(audioCtx.destination); osc.start(); osc.stop(audioCtx.currentTime 1);这段代码做了三件事创建振荡器作为声音源。创建增益节点控制音量。把振荡器和增益节点连接最终连接到输出设备。鼓声合成也是同样的思路只不过多了频率包络、噪声源和更复杂的增益变化。3.2 鼓音的合成原理真实的鼓声很难完全用合成器还原但做节奏编排时我们只需合成出“够用”的音色。下面是四种常见鼓音色的基本做法。底鼓 Kick 的灵魂是“音高快速下降”。用正弦振荡器频率从 150Hz 快速滑落到 45Hz同时音量在 0.3 秒内衰减到接近 0听起来就是经典的电子底鼓。军鼓 Snare 比底鼓复杂一点。它由两部分组成一段白噪声经过带通滤波后作为“沙带”声再加上一个低频三角波作为鼓腔震动。两者叠加后经过增益包络输出。闭镲 Hi-Hat 主要靠噪声源。白噪声经过一个高通滤波器只保留高频成分再配一个很短的音量包络通常在 0.1 秒内衰减完产生清脆的声响。拍手 Clap 可以理解为多次短促的噪声爆发每次间隔十几毫秒模拟多人掌声叠加的效果。下面这段代码演示了底鼓的合成思路function playKick(time) { const ctx audioCtx; const osc ctx.createOscillator(); const gain ctx.createGain(); osc.type sine; osc.frequency.setValueAtTime(150, time); osc.frequency.exponentialRampToValueAtTime(45, time 0.12); gain.gain.setValueAtTime(0.9, time); gain.gain.exponentialRampToValueAtTime(0.001, time 0.3); osc.connect(gain); gain.connect(ctx.destination); osc.start(time); osc.stop(time 0.35); }注意setValueAtTime和exponentialRampToValueAtTime。前者在指定时间点设置值后者让值以指数方式过渡到目标值。指数变化更接近真实鼓声的能量衰减。3.3 音序器的时间调度原理这是整个项目最值得重视的部分。最直觉的做法是用setInterval每隔 125ms 触发一个 16 分音符但这样有一个严重问题主线程一旦被其他任务阻塞定时器会延迟累积起来节奏就飘了。同时setInterval的实际触发时间和你希望的发声时间有微小的偏差鼓声听感会“黏糊”。正确做法是角色反转setInterval只负责“定期检查未来”真正的发声时间全部交给AudioContext.currentTime来精确安排。算法可以这样描述设置一个 nextNoteTime 变量表示下一个音符应该发生的音频时间。 设置一个 scheduleAheadTime例如 0.1 秒表示提前调度的时间窗口。 每 25ms 循环一次 只要 nextNoteTime 小于 currentTime scheduleAheadTime 就在 nextNoteTime 时刻触发当前步骤的所有音色 当前步骤加 1 nextNoteTime 增加一个音符时长这个称为“lookahead scheduler”的方案可以很好地避免节奏漂移。声音不是“现在播放”而是“预约在未来的某个音频时间播放”所以即使 JS 主线程抖动几十毫秒音频时钟依然稳定。16 分音符时长的计算公式是stepDuration 60 / BPM / 4例如 BPM 为 120那么60 / 120 / 4 0.125 秒这正好是一个 16 分音符的时长。3.4 界面状态与播放状态如何联动音序器界面需要做两件事反映用户编辑的 pattern以及反映当前播放位置。如果这两件事耦合写在一起代码很容易乱。比如播放过程中用户点击了某个格子界面既要高亮格子又不能影响正在移动的播放头。更好的方式是数据驱动pattern 数据存在一个二维数组tracks[trackIndex].steps[stepIndex]中。界面渲染时根据这个数组生成格子并给格子设置on类。播放头更新时单独遍历当前列给格子设置playing类。这样用户编辑和播放高亮互不干扰也不需要在播放时反复重建整个 DOM。4. 完整实战从零实现 Web Drum Sequencer下面进入完整实现。为了便于运行我会按文件顺序给出完整代码。4.1 创建 index.html 页面骨架!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleWeb Drum Sequencer - 网页版鼓机音序器/title link relstylesheet hrefcss/style.css / /head body div idapp header classtoolbar h1Web Drum Sequencer/h1 div classtransport button idplayBtn classplay-btn▶ 播放/button div classbpm-box label forbpmSliderBPM/label input typerange idbpmSlider min60 max180 value120 / span idbpmValue120/span /div button idclearBtn classclear-btn清空/button /div /header main idgridBoard classgrid-board/main p classtip提示点击格子编辑节奏点击音轨名称可以静音。/p /div script srcjs/audioEngine.js/script script srcjs/sequencer.js/script script srcjs/ui.js/script script srcjs/main.js/script /body /html这里把大部分 UI 结构放在了 HTML 中格子部分则由 JavaScript 动态生成。原因是鼓机轨道的数量和每个轨道的步数可能变化写死在 HTML 里反而不灵活。4.2 编写 style.css 网格样式:root { --bg: #0e1116; --panel: #151b23; --text: #e6edf3; --accent: #f78154; --cell-off: #1c2530; --cell-on: #f78154; --cell-muted: #3a4452; } * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font-family: Segoe UI, PingFang SC, Microsoft YaHei, sans-serif; } #app { max-width: 1000px; margin: 0 auto; padding: 24px; } .toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; margin-bottom: 24px; } .toolbar h1 { font-size: 22px; margin: 0; } .transport { display: flex; align-items: center; gap: 16px; } .play-btn, .clear-btn { padding: 8px 16px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; background: var(--accent); color: #111; } .clear-btn { background: #2a3441; color: var(--text); } .bpm-box { display: flex; align-items: center; gap: 8px; } #bpmSlider { width: 120px; } .grid-board { display: grid; grid-template-columns: 100px repeat(16, 1fr); gap: 6px; background: var(--panel); padding: 16px; border-radius: 12px; } .track-label { display: flex; align-items: center; justify-content: center; height: 44px; background: #1c2530; border-radius: 6px; cursor: pointer; user-select: none; font-size: 14px; } .track-label.muted { opacity: 0.4; text-decoration: line-through; } .step-cell { height: 44px; background: var(--cell-off); border-radius: 6px; cursor: pointer; transition: background 0.1s ease; } .step-cell.on { background: var(--cell-on); } .step-cell.playing { box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.7); } .tip { margin-top: 16px; color: #8b949e; font-size: 13px; }CSS Grid 是关键。grid-template-columns: 100px repeat(16, 1fr)表示第一列固定 100px 作为音轨名标签后面 16 列平均分配正好对应 16 步。4.3 实现 audioEngine.js 音频引擎// 文件路径js/audioEngine.js class AudioEngine { constructor() { this.ctx null; this.noiseBuffer null; } ensureContext() { if (!this.ctx) { const AC window.AudioContext || window.webkitAudioContext; if (!AC) { throw new Error(当前浏览器不支持 Web Audio API); } this.ctx new AC(); this.noiseBuffer this.createNoiseBuffer(); } if (this.ctx.state suspended) { this.ctx.resume(); } return this.ctx; } createNoiseBuffer() { const ctx this.ctx; const bufferSize ctx.sampleRate; const buffer ctx.createBuffer(1, bufferSize, ctx.sampleRate); const data buffer.getChannelData(0); for (let i 0; i data.length; i) { data[i] Math.random() * 2 - 1; } return buffer; } playTrack(name, time) { switch (name) { case kick: this.playKick(time); break; case snare: this.playSnare(time); break; case hihat: this.playHihat(time); break; case clap: this.playClap(time); break; default: break; } } playKick(time) { const ctx this.ctx; const osc ctx.createOscillator(); const gain ctx.createGain(); osc.type sine; osc.frequency.setValueAtTime(150, time); osc.frequency.exponentialRampToValueAtTime(45, time 0.12); gain.gain.setValueAtTime(0.9, time); gain.gain.exponentialRampToValueAtTime(0.001, time 0.3); osc.connect(gain); gain.connect(ctx.destination); osc.start(time); osc.stop(time 0.35); } playSnare(time) { const ctx this.ctx; const noise ctx.createBufferSource(); noise.buffer this.noiseBuffer; const bandpass ctx.createBiquadFilter(); bandpass.type bandpass; bandpass.frequency.value 1800; bandpass.Q.value 0.8; const noiseGain ctx.createGain(); noiseGain.gain.setValueAtTime(0.6, time); noiseGain.gain.exponentialRampToValueAtTime(0.001, time 0.2); noise.connect(bandpass); bandpass.connect(noiseGain); noiseGain.connect(ctx.destination); noise.start(time); noise.stop(time 0.25); const osc ctx.createOscillator(); const oscGain ctx.createGain(); osc.type triangle; osc.frequency.setValueAtTime(190, time); oscGain.gain.setValueAtTime(0.35, time); oscGain.gain.exponentialRampToValueAtTime(0.001, time 0.1); osc.connect(oscGain); oscGain.connect(ctx.destination); osc.start(time); osc.stop(time 0.15); } playHihat(time) { const ctx this.ctx; const noise ctx.createBufferSource(); noise.buffer this.noiseBuffer; const highpass ctx.createBiquadFilter(); highpass.type highpass; highpass.frequency.value 7000; const gain ctx.createGain(); gain.gain.setValueAtTime(0.35, time); gain.gain.exponentialRampToValueAtTime(0.001, time 0.08); noise.connect(highpass); highpass.connect(gain); gain.connect(ctx.destination); noise.start(time); noise.stop(time 0.12); } playClap(time) { for (let i 0; i 3; i) { const t time i * 0.012; const noise this.ctx.createBufferSource(); noise.buffer this.noiseBuffer; const bandpass this.ctx.createBiquadFilter(); bandpass.type bandpass; bandpass.frequency.value 1200; const gain this.ctx.createGain(); gain.gain.setValueAtTime(0.4, t); gain.gain.exponentialRampToValueAtTime(0.001, t 0.05); noise.connect(bandpass); bandpass.connect(gain); gain.connect(this.ctx.destination); noise.start(t); noise.stop(t 0.07); } } }这里有几个设计细节值得说明。噪声 buffer 只需要创建一次所有噪声音色共用避免每次播放都重新生成 1 秒长的随机数数组既省内存也省计算。所有发声方法都接收一个time参数这个参数不是“推迟播放”的宽松拖延而是AudioContext的精确时间。实际调用时传的是sequencer算好的nextNoteTime。playSnare用了两条声音路径叠加。一条是滤波噪声一条是三角波。两者通过各自的增益节点控制音量最终都连到输出。4.4 实现 sequencer.js 音序调度器// 文件路径js/sequencer.js class Sequencer { constructor(engine, tracks, steps 16) { this.engine engine; this.tracks tracks; this.steps steps; this.bpm 120; this.isPlaying false; this.currentStep -1; this.nextNoteTime 0; this.timerId null; this.lookahead 25; this.scheduleAheadTime 0.1; this.onStepChange null; } get stepDuration() { return 60 / this.bpm / 4; } toggleStep(trackIndex, stepIndex) { const track this.tracks[trackIndex]; track.steps[stepIndex] !track.steps[stepIndex]; } play() { if (this.isPlaying) return; const ctx this.engine.ensureContext(); this.isPlaying true; this.currentStep -1; this.nextNoteTime ctx.currentTime 0.06; this.timerId setInterval(() { this.scheduler(); }, this.lookahead); } scheduler() { const ctx this.engine.ctx; while (this.nextNoteTime ctx.currentTime this.scheduleAheadTime) { this.currentStep (this.currentStep 1) % this.steps; this.scheduleStep(this.currentStep, this.nextNoteTime); if (this.onStepChange) { this.onStepChange(this.currentStep); } this.nextNoteTime this.stepDuration; } } scheduleStep(stepIndex, time) { this.tracks.forEach((track) { if (track.muted) return; if (track.steps[stepIndex]) { this.engine.playTrack(track.name, time); } }); } stop() { if (!this.isPlaying) return; this.isPlaying false; clearInterval(this.timerId); this.timerId null; if (this.onStepChange) { this.onStepChange(-1); } } }lookahead是调度器每隔多少毫秒“醒来”检查一次示例中设为 25ms。scheduleAheadTime表示提前调度未来多少秒的音符示例中为 0.1 秒。这两个参数不是固定的可以按性能调整。每次循环只要nextNoteTime还在未来 0.1 秒的窗口内就结算一个音符然后推进到下一个 16 分音符。这样即使主线程偶尔卡一下已经提前放入 Web Audio 时间轴的声音依然会按原计划触发节奏不会乱。4.5 实现 ui.js 界面交互// 文件路径js/ui.js class UI { constructor(sequencer, tracks) { this.sequencer sequencer; this.tracks tracks; this.gridBoard document.getElementById(gridBoard); this.playBtn document.getElementById(playBtn); this.bpmSlider document.getElementById(bpmSlider); this.bpmValue document.getElementById(bpmValue); this.clearBtn document.getElementById(clearBtn); this.sequencer.onStepChange (step) this.updatePlayhead(step); } init() { this.renderGrid(); this.bindEvents(); } renderGrid() { this.gridBoard.innerHTML ; const corner document.createElement(div); corner.className track-label; corner.textContent 音轨; this.gridBoard.appendChild(corner); for (let i 0; i this.sequencer.steps; i) { const stepLabel document.createElement(div); stepLabel.className track-label; stepLabel.textContent String(i 1).padStart(2, 0); this.gridBoard.appendChild(stepLabel); } this.tracks.forEach((track, trackIndex) { const label document.createElement(div); label.className track-label; label.textContent track.label; label.addEventListener(click, () { track.muted !track.muted; label.classList.toggle(muted, track.muted); }); this.gridBoard.appendChild(label); for (let stepIndex 0; stepIndex this.sequencer.steps; stepIndex) { const cell document.createElement(div); cell.className step-cell; cell.dataset.track trackIndex; cell.dataset.step stepIndex; if (track.steps[stepIndex]) { cell.classList.add(on); } this.gridBoard.appendChild(cell); } }); } bindEvents() { this.playBtn.addEventListener(click, () { if (this.sequencer.isPlaying) { this.sequencer.stop(); this.playBtn.textContent ▶ 播放; } else { this.sequencer.play(); this.playBtn.textContent ⏸ 停止; } }); this.bpmSlider.addEventListener(input, () { const bpm Number(this.bpmSlider.value); this.sequencer.bpm bpm; this.bpmValue.textContent bpm; }); this.clearBtn.addEventListener(click, () { this.tracks.forEach((track) { track.steps.fill(false); }); this.gridBoard.querySelectorAll(.step-cell).forEach((cell) { cell.classList.remove(on); }); }); this.gridBoard.addEventListener(click, (event) { const cell event.target.closest(.step-cell); if (!cell) return; const trackIndex Number(cell.dataset.track); const stepIndex Number(cell.dataset.step); this.sequencer.toggleStep(trackIndex, stepIndex); cell.classList.toggle(on, this.tracks[trackIndex].steps[stepIndex]); }); } updatePlayhead(step) { this.gridBoard.querySelectorAll(.step-cell.playing).forEach((cell) { cell.classList.remove(playing); }); if (step 0) return; this.gridBoard.querySelectorAll(.step-cell[data-step${step}]).forEach((cell) { cell.classList.add(playing); }); } }播放高亮更新时先移除所有playing类再给当前步骤的所有格子加上高亮。因为每行相同 step 的格子都需要点亮所以用>// 文件路径js/main.js const tracks [ { name: kick, label: Kick, muted: false, steps: [true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false] }, { name: snare, label: Snare, muted: false, steps: [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false] }, { name: hihat, label: Hi-Hat, muted: false, steps: [true, false, true, false, true, false, true, true, true, false, true, false, true, false, true, true] }, { name: clap, label: Clap, muted: false, steps: [false, false, false, true, false, false, false, false, false, true, false, false, false, false, false, false] } ]; const engine new AudioEngine(); const sequencer new Sequencer(engine, tracks, 16); const ui new UI(sequencer, tracks); ui.init();初始化时给 tracks 设置了一个默认节奏。点击播放后你能立刻听到一段四小节循环的鼓点方便验证音频引擎是否正常。tracks数组被同时传给Sequencer和UI两边引用的是同一个数组。因此用户点击格子后UI修改了track.steps[stepIndex]Sequencer在播放时能读到最新值。4.7 运行与验证运行方式有两种。方式一直接用 VS Code 的 Live Server 打开项目目录。方式二在项目根目录执行 Python 内置服务器python3 -m http.server 8000然后打开http://localhost:8000预期效果页面出现 4 行 16 列的鼓机网格。点击“播放”按钮按钮文字变为“停止”格子从第 1 步开始循环高亮。默认能听到底鼓、军鼓、闭镲和拍手的组合节奏。点击任意格子能添加或移除该音色在对应步上的触发。拖动 BPM 滑块播放速度实时变化。点击音轨名称该行变暗并静音。点击“清空”所有格子变为未点亮状态。5. 常见问题与排查思路5.1 点击播放没有声音这是网页音频项目里最高频的问题绝大多数情况下和浏览器自动播放策略有关。浏览器出于用户体验考虑不允许页面在没有任何用户交互的情况下自动播放声音。AudioContext 在初始化时通常处于suspended状态直到用户进行点击、键盘操作等交互后才允许恢复。解决方式是在用户手势回调里调用resume。本文代码中play()方法在点击按钮时执行audioEngine.ensureContext()内部会判断状态并调用ctx.resume()所以能正常出声。如果你在控制台看到类似The AudioContext was not allowed to start的提示可以检查是否在异步回调中延迟创建了 AudioContext。尽量让 AudioContext 的创建和恢复都发生在用户点击事件同步代码中。5.2 声音延迟明显如果点击格子的瞬间声音响应慢或者播放节奏听起来黏糊原因通常是你在音序调度时直接用setInterval的 tick 来触发声音。setInterval的最小间隔、主线程负载、浏览器节流策略都会影响触发时间。正确做法不是减少setInterval的间隔而是利用 Web Audio 的调度机制提前安排好未来几十毫秒的声音。本文的 lookahead scheduler 就是为此设计的。如果你改成更复杂的项目还可以使用AudioWorklet把调度循环放进音频线程延迟可以更低。5.3 长时间运行后节奏漂移如果节奏越跑越偏说明时间计算依赖了累计的 JavaScript 计数比如每次setInterval回调后让某个计数器加一然后用计数器乘以间隔计算时间。这种方式受主线程阻塞影响很大。比如某个 25ms 的 interval 被阻塞到 200ms计数器加的还是一但实际已经过去了 8 个音符的时间。稳定方案是像本文一样用AudioContext.currentTime作为基准每次循环判断nextNoteTime与当前音频时间的关系。5.4 移动端或非 localhost 环境无法出声Web Audio API 在不少浏览器中要求页面运行在安全上下文HTTPS 或 localhost中。如果页面通过局域网 IP 访问且没有 HTTPSAudioContext可能不存在或无法启动。开发调试时尽量使用localhost或配置 HTTPS 的域名。本地调试时启动一个 HTTP Server 访问 localhost 即可不要直接双击 HTML 文件。另外iOS Safari 对音频上下文数量、噪声 buffer 大小可能有限制。遇到奇怪问题时可以先在桌面 Chrome 上验证再针对移动端做兼容调整。常见问题汇总如下问题现象常见原因解决思路点击播放没有声音浏览器自动播放策略在用户手势中创建或恢复 AudioContext声音明显延迟setInterval 直接触发声音改用 lookahead audio time 调度长时间播放后节奏漂移用 JS 计数估算时间使用 AudioContext.currentTime 做基准iOS 无声secure context 限制使用 HTTPS 或 localhost兼容 webkitAudioContext格子高亮卡顿播放头更新频繁触发重渲染只操作样式类不做整表重建6. 最佳实践与工程建议6.1 音频节点生命周期管理Web Audio 的音频节点不会自动从音频图消失除非你停止源节点并切断连接。每次播放底鼓时我们都创建了新的 oscillator 和 gain并让声音在几百毫秒后停止。如果一个音轨以很高的频率持续触发长时间运行后会创建大量节点。虽然 Web Audio 引擎内部有垃圾回收机制但最好在源码中明确管理生命周期所有BufferSourceNode、OscillatorNode都调用stop(time)。在stop后可以监听onended事件断开节点连接。实际项目中可以编写一个辅助方法创建声音源并在onended中调用disconnect。6.2 时间调度不要依赖 setInterval这值得再强调一次。setInterval在 web 音频场景里只适合做“定时检查”不适合做“声音触发”。如果把声音触发放进 interval 回调一旦页面切到后台、主线程执行长任务节奏就会停顿或变慢。而把声部触发放到AudioContext时间轴上声音实际播放由浏览器音频线程负责稳定性会好很多。Android 上部分浏览器在页面切后台后还会反复暂时冻结 interval这也是为什么你必须把声音触发放到音频时间轴上的原因之一。6.3 UI 渲染与音频逻辑分离鼓机界面看起来简单但状态很容易互相污染。一个建议是让音序器对象不直接操作 DOM。Sequencer只负责维护播放状态、计算时间、调度音色UI只负责渲染格子、收集用户操作。两者通过onStepChange这样的回调通信。这样做的好处是后续想增加另一个视图比如钢琴卷帘或波形显示不需要改音频调度代码。单元测试可以单独测试 Sequencer 的逻辑不依赖 DOM。播放状态可以在不同组件间共享不容易出现 UI 显示和实际声音不一致。6.4 性能优化与移动端适配当前示例只有 4 轨、64 个格子性能压力很小。但如果你把轨道数增加到 16 轨或者加入更多复杂音色就需要注意以下几点。第一控制 DOM 节点数量。所有格子最好通过一次遍历生成不要频繁innerHTML拼接。updatePlayhead只切换当前列的样式类不要每次都重建列表。第二噪声 buffer 复用。不要在每个音符触发时创建新的噪声 buffer而是像示例那样创建一次多个音色共享读取。第三合并 UI 更新。播放头每步更新一次没问题但如果你还想显示波形或频谱可以使用requestAnimationFrame限流渲染频率不要在每个调度 tick 里同步做复杂计算。第四移动端适配。格子高度可以改用相对单位或者根据屏幕宽度调整 CSS Grid 列数。如果页面在手机上显示拥挤可以让面板横向滚动而不是强行压缩格子尺寸。6.5 更进阶的扩展方向一个基础的 16 步鼓机完成后能扩展的方向非常多。音序器方面可以支持多 Pattern、歌曲编排、Swing 摇摆、三连音、自动保存到 localStorage或者通过 URL 参数分享 rhythm。音色方面可以改用采样文件加入调音、包络、滤波、混响等效果器。Web Audio 的ConvolverNode可以直接做卷积混响WaveShaperNode可以做失真。输入输出方面可以接入 Web MIDI API让外接 MIDI 键盘或打击垫直接控制格子。也可以把当前 pattern 导出为 MID
返回列表