尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用 Web Audio API 打造单文件 Techno Machine:从音序器到可验证渲染

用 Web Audio API 打造单文件 Techno Machine:从音序器到可验证渲染 一份 HTML 文件能装下什么对前端工程师而言它是一个页面、一个小工具、一份简历但对 Web Audio 的开发者来说一个 HTML 文件完全可以是一台电子音乐机器——内置底鼓、踩镲、贝斯和军鼓音色打开浏览器就出声并且每一次渲染都会留下可审计的时间和参数记录。这篇博客要拆解的正是一个典型的单文件 Techno Machine 项目UI、音频引擎、音序调度和可视化全部压缩在一个 HTML 文件里同时通过渲染日志让播放过程“可验证”。它解决的痛点很明确传统音乐制作工具依赖重、学习成本高、难以分享而“单 HTML 文件 Web Audio API”把做一台电子节奏机器这件事压缩到了打开浏览器这一个步骤。读完这篇文章你会理解单文件音频项目的架构如何分层会用 Web Audio API 写出 Kick、Hi-Hat、Snare、Bass 四类基础音色还会设计一套可验证的渲染调度机制。文章会给出完整可运行的代码并补充常见问题排查和工程化建议。1. 单文件 Techno Machine解决的不只是“好玩”接触过 Web Audio 的开发者都知道让浏览器“出声”并不难难的是让声音变得有结构、可控制、可复现。很多人第一次写音频 Demo往往是在按钮点击事件里直接创建一个 OscillatorNode响一下就没有然后了。而一个 Techno Machine 之所以是好的练习对象是因为它天然把问题拆成了几层节奏模式怎么表达音符在什么时间点触发每种音色用哪些音频节点组合播放过程怎样被记录和验证。单文件方案看起来是“图省事”但在工程上它有一个非常实际的好处依赖为零分享即运行。你不需要安装 DAW、不需要配置包管理、不需要构建工具。把文件发给别人双击打开就是一台能用的节奏机器。这种“最小可交付”的形态在学习和原型验证阶段价值很高。真正值得关注的是这个项目对“可验证渲染”的处理。音频程序的输出是时间轴上的声音事件如果没有渲染日志你很难判断一个音符是“没触发”还是“触发了但听不见”也很难确认调度是否精准。在单文件 Demo 里加入渲染验证层相当于给程序装了仪表盘它让调试从“靠耳朵猜”变成了“看数据定位”。这篇文章适合三类读者想入门 Web Audio 的前端开发者、想理解音序器原理的音乐编程爱好者、以及正在做课设或作品集项目、需要一个小而完整的可演示项目的同学。2. 核心概念音序器、Web Audio 与可验证渲染2.1 音序器到底是什么音序器Sequencer是电子音乐设备中负责“按时间顺序触发音符”的模块。硬件时代它是独立的合成器设备软件时代它变成 DAW数字音频工作站里的钢琴卷帘窗。它的核心数据结构其实很简单一张表格行是音轨列是时间步进格子里是“触发/不触发”。在我们的 Techno Machine 里这就是一个二维数组const tracks { kick: [1,0,0,0, 1,0,0,0, 1,0,0,0, 1,0,0,0], hat: [1,1,1,1, 1,1,1,1, 1,1,1,1, 1,1,1,1], bass: [1,0,1,0, 1,0,1,0, 1,0,1,0, 1,0,1,0], snare: [0,0,0,0, 1,0,0,0, 0,0,0,0, 1,0,0,0] };每个数组有 16 个元素对应一个四四拍小节里的 16 个 16 分音符。值为 1 表示该步触发对应音色值为 0 表示静音。音序器要做的就是在正确的时刻读取这张表交给音频引擎去合成。2.2 Web Audio API 的基本工作方式Web Audio API 和普通audio标签最大的区别是它不直接“播放文件”而是构建一张音频节点图Audio Graph。常见的节点包括节点作用类比AudioContext音频上下文所有节点的宿主音频设备的总电源OscillatorNode产生周期性波形如正弦波、方波、锯齿波模拟合成器的振荡器GainNode控制音量配合包络实现打击感混音台上的推子BiquadFilterNode滤波改变音色的频段特质均衡器或滤波器旋钮AudioBufferSourceNode播放内存中的音频采样采样器节点之间用connect()连接最终接入audioCtx.destination扬声器。Web Audio 的一个重要特性是可以按音频时钟精确调度你告诉一个节点“在 t 时刻开始发声、在 td 时刻停止”浏览器会在音频线程上精确执行而不是受 JavaScript 主线程卡顿影响。这正是实现稳定音序器的基石。2.3 可验证渲染的含义“可验证渲染”在这个项目里有两层意思。第一层调度结果可追溯。每一次触发音色时代码都向日志数组写入一条记录内容包括音色类型、步进索引、目标时间偏移。这条记录可以展示在页面上也可以被外部程序读取用来核对“这个音符到底有没有被调度”。第二层渲染结果是确定性的。在固定 tempo、固定 pattern 的前提下音符的触发时间序列应当可复现。哪怕不同浏览器、不同电脑只要音频时钟稳定相邻步进的间隔都应该一致。这种确定性对音乐软件尤其重要因为它决定了你在 Chrome 里做的编排能不能在其他环境里还原出同样的律动。3. 整体架构一个 HTML 文件里如何分层单文件不等于单层。如果把所有代码揉在一起后续调试会非常痛苦。即使只有一个 HTML 文件也应该保持清晰的模块边界。我建议把整个应用拆成五层分层职责示例UI 层按钮、滑块、步进灯、日志显示HTML/CSS 与 DOM 操作状态层保存 tempo、currentStep、isPlaying普通 JavaScript 变量音频引擎层每种音色的合成函数playKick、playHat调度层按音频时钟安排音符触发lookahead scheduler验证层记录渲染日志并展示renderLog 数组与日志面板这样划分的原因是改 UI 不需要动音频引擎改音色不需要动调度逻辑验证层是独立插件。哪怕项目变大到几百行这种边界依然能让你快速定位问题。一个容易犯的错误是在setInterval回调里直接创建并启动所有音频节点。这个方案看起来简单但 JavaScript 主线程一旦被其他任务阻塞音符就会集体漂移。正确做法是用一个短间隔的调度器不断检查“当前时间 调度提前量”内有没有需要触发的音符然后一次性把未来 0.1 秒内的音符安排好。第 5 节的代码就是这个思路。4. 音色合成Kick、Hi-Hat、Snare、Bass 的实现思路电子音乐里四类最基础的声音分别是底鼓Kick、踩镲Hi-Hat、军鼓Snare和贝斯Bass。它们可以用很短的代码合成出来这里先讲设计思路完整代码在第 5 节。4.1 Kick频率下滑 增益包络Techno 底鼓的经典做法是用一个正弦波振荡器让频率从 150Hz 快速指数下滑到 40Hz同时让增益从 1 快速衰减到接近 0。频率下滑产生“咚”的冲击感增益衰减产生短促的打击感。function playKick(time) { const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.type sine; osc.frequency.setValueAtTime(150, time); osc.frequency.exponentialRampToValueAtTime(40, time 0.1); gain.gain.setValueAtTime(1, time); gain.gain.exponentialRampToValueAtTime(0.001, time 0.3); osc.connect(gain); gain.connect(masterGain); osc.start(time); osc.stop(time 0.3); }这里有两个关键细节。第一频率使用exponentialRampToValueAtTime而不是linearRampToValueAtTime因为人的听感对频率变化是对数感知的指数下滑更自然。第二增益必须从一个明确值开始再用指数衰减如果直接把 gain 从 0 开始会听不到起始冲击。4.2 Hi-Hat 与 Snare噪声 滤波踩镲的本质是高频噪声。先创建一个 1 秒的白噪声 AudioBuffer然后用高通滤波器把低频去掉只留高频段再用极短的增益包络让它变成“哒”的一声。军鼓和踩镲类似但滤波器用带通约 1800Hz包络稍长听起来更像“啪”而不是“哒”。噪声采样可以复用一个 buffer不需要为每次触发重新生成。4.3 Bass锯齿波 低通滤波贝斯音色用锯齿波sawtooth作为基础波形锯齿波谐波丰富很适合电子音乐。经过一个低通滤波器约 300Hz削掉多余高频再用短促的增益包络控制音量。function playBass(time, stepIndex) { const root 55; // A1 const octave Math.floor(stepIndex / 4) % 2 0 ? 0 : 12; const osc audioCtx.createOscillator(); const filter audioCtx.createBiquadFilter(); const gain audioCtx.createGain(); osc.type sawtooth; osc.frequency.value root * Math.pow(2, octave / 12); filter.type lowpass; filter.frequency.value 320; gain.gain.setValueAtTime(0.3, time); gain.gain.exponentialRampToValueAtTime(0.001, time 0.22); osc.connect(filter); filter.connect(gain); gain.connect(masterGain); osc.start(time); osc.stop(time 0.22); }代码里每隔 4 步让贝斯高一个八度形成一个简单的律动变化。实际项目中贝斯音高通常来自和弦或音阶表你可以把root换成数组按 pattern 取值。4.4 合成层小结Web Audio 的合成本质上是在做三件事选波形、选滤波、做包络。三者分别决定了音色的“气质”“色彩”和“打击感”。上面的参数150Hz 到 40Hz 的 kick 下滑、7000Hz 的高通截止是经典起点不是唯一答案。动手改参数、听差异比照抄参数更能建立直觉。5. 完整示例代码Mini Techno Machine下面是完整可运行的代码。保存为index.html用 Chrome 或 Edge 打开点击 Play 即可听到四轨 Techno 循环。代码里已经包含了渲染日志机制你会看到每一枪音色的触发记录。!DOCTYPE html html langzh-CN head meta charsetUTF-8 titleMini Techno Machine/title style body { font-family: system-ui, -apple-system, sans-serif; background: #111; color: #e0e0e0; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; } .machine { background: #1e1e1e; border-radius: 12px; padding: 24px; width: 640px; } .controls { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; } button { background: #0d9488; border: none; color: white; padding: 8px 18px; border-radius: 6px; font-size: 16px; cursor: pointer; } button:hover { background: #0f766e; } .steps { display: grid; grid-template-columns: repeat(16, 1fr); gap: 4px; margin-bottom: 12px; } .step { height: 24px; background: #333; border-radius: 4px; transition: background 0.05s; } .step.active { background: #fbbf24; } #log { background: #000; border: 1px solid #333; border-radius: 6px; padding: 10px; height: 180px; overflow-y: auto; font-family: monospace; font-size: 12px; white-space: pre-wrap; } .hint { font-size: 12px; color: #888; margin-top: 8px; } /style /head body div classmachine h2Mini Techno Machine/h2 div classcontrols button idplayBtnPlay/button button idstopBtnStop/button labelTempo: input typerange idtempo min80 max180 value128/label span idtempoLabel128 BPM/span /div div idsteps classsteps/div h3Render Log/h3 div idlog---/div div classhint逻辑lookahead scheduler 渲染日志。推荐 Chrome / Edge 打开。/div /div script (function () { const STEPS 16; let audioCtx null; let masterGain null; let noiseBuffer null; let isPlaying false; let currentStep 0; let nextNoteTime 0; let startTime 0; let schedulerTimer null; const tracks { kick: [1,0,0,0, 1,0,0,0, 1,0,0,0, 1,0,0,0], hat: [1,1,1,1, 1,1,1,1, 1,1,1,1, 1,1,1,1], bass: [1,0,1,0, 1,0,1,0, 1,0,1,0, 1,0,1,0], snare: [0,0,0,0, 1,0,0,0, 0,0,0,0, 1,0,0,0] }; const renderLog []; const MAX_LOG_LINES 200; function ensureAudioContext() { if (!audioCtx) { const Ctor window.AudioContext || window.webkitAudioContext; audioCtx new Ctor(); masterGain audioCtx.createGain(); masterGain.gain.value 0.8; masterGain.connect(audioCtx.destination); noiseBuffer createNoiseBuffer(); } if (audioCtx.state suspended) { audioCtx.resume(); } return audioCtx; } function createNoiseBuffer() { const buffer audioCtx.createBuffer(1, audioCtx.sampleRate, audioCtx.sampleRate); const data buffer.getChannelData(0); for (let i 0; i data.length; i) { data[i] Math.random() * 2 - 1; } return buffer; } function playKick(time) { const osc audioCtx.createOscillator(); const gain audioCtx.createGain(); osc.type sine; osc.frequency.setValueAtTime(150, time); osc.frequency.exponentialRampToValueAtTime(40, time 0.1); gain.gain.setValueAtTime(1, time); gain.gain.exponentialRampToValueAtTime(0.001, time 0.3); osc.connect(gain); gain.connect(masterGain); osc.start(time); osc.stop(time 0.3); } function playHat(time) { const source audioCtx.createBufferSource(); const filter audioCtx.createBiquadFilter(); const gain audioCtx.createGain(); source.buffer noiseBuffer; filter.type highpass; filter.frequency.value 7000; gain.gain.setValueAtTime(0.25, time); gain.gain.exponentialRampToValueAtTime(0.001, time 0.05); source.connect(filter); filter.connect(gain); gain.connect(masterGain); source.start(time); source.stop(time 0.05); } function playSnare(time) { const source audioCtx.createBufferSource(); const filter audioCtx.createBiquadFilter(); const gain audioCtx.createGain(); source.buffer noiseBuffer; filter.type bandpass; filter.frequency.value 1800; gain.gain.setValueAtTime(0.4, time); gain.gain.exponentialRampToValueAtTime(0.001, time 0.12); source.connect(filter); filter.connect(gain); gain.connect(masterGain); source.start(time); source.stop(time 0.12); } function playBass(time, stepIndex) { const root 55; const octave Math.floor(stepIndex / 4) % 2 0 ? 0 : 12; const osc audioCtx.createOscillator(); const filter audioCtx.createBiquadFilter(); const gain audioCtx.createGain(); osc.type sawtooth; osc.frequency.value root * Math.pow(2, octave / 12); filter.type lowpass; filter.frequency.value 320; gain.gain.setValueAtTime(0.3, time); gain.gain.exponentialRampToValueAtTime(0.001, time 0.22); osc.connect(filter); filter.connect(gain); gain.connect(masterGain); osc.start(time); osc.stop(time 0.22); } function scheduleStep(stepIndex, time) { if (tracks.kick[stepIndex]) { playKick(time); record(kick, stepIndex, time); } if (tracks.hat[stepIndex]) { playHat(time); record(hat, stepIndex, time); } if (tracks.bass[stepIndex]) { playBass(time, stepIndex); record(bass, stepIndex, time); } if (tracks.snare[stepIndex]) { playSnare(time); record(snare, stepIndex, time); } updateStepDisplay(stepIndex); } function record(kind, stepIndex, time) { const offset (time - startTime).toFixed(4); renderLog.push({ kind, step: stepIndex, offset }); if (renderLog.length MAX_LOG_LINES) { renderLog.shift(); } appendLogLine(kind step stepIndex offset offset s); } function scheduler() { while (nextNoteTime audioCtx.currentTime 0.1) { const idx currentStep % STEPS; scheduleStep(idx, nextNoteTime); nextNoteTime 60.0 / getTempo() / 4; currentStep; } } function getTempo() { return parseInt(document.getElementById(tempo).value, 10); } const stepEls []; function buildStepGrid() { const container document.getElementById(steps); container.innerHTML ; stepEls.length 0; for (let i 0; i STEPS; i) { const div document.createElement(div); div.className step; container.appendChild(div); stepEls.push(div); } } function updateStepDisplay(activeIndex) { for (let i 0; i stepEls.length; i) { stepEls[i].classList.remove(active); } stepEls[activeIndex].classList.add(active); } function clearStepDisplay() { for (let i 0; i stepEls.length; i) { stepEls[i].classList.remove(active); } } const logEl document.getElementById(log); function appendLogLine(line) { if (logEl.textContent ---) { logEl.textContent ; } logEl.textContent line \n; logEl.scrollTop logEl.scrollHeight; } document.getElementById(playBtn).addEventListener(click, function () { ensureAudioContext(); if (isPlaying) return; isPlaying true; currentStep 0; startTime audioCtx.currentTime; nextNoteTime startTime 0.05; renderLog.length 0; logEl.textContent ---\n; schedulerTimer setInterval(scheduler, 25); }); document.getElementById(stopBtn).addEventListener(click, function () { if (!isPlaying) return; isPlaying false; clearInterval(schedulerTimer); schedulerTimer null; clearStepDisplay(); logEl.textContent -- stopped --\n; }); const tempoInput document.getElementById(tempo); const tempoLabel document.getElementById(tempoLabel); tempoInput.addEventListener(input, function () { tempoLabel.textContent tempoInput.value BPM; }); buildStepGrid(); })(); /script /body /html5.1 代码结构说明ensureAudioContext()负责懒加载 AudioContext并在浏览器自动暂停后恢复。所有 AudioContext 的创建都发生在用户点击 Play 的事件里这是满足浏览器自动播放策略的标准做法。createNoiseBuffer()生成 1 秒白噪声被 Hi-Hat 和 Snare 共用。playKick、playHat、playSnare、playBass是四个独立的合成函数输入一个时间参数在那个时间点精确发声。scheduleStep()是调度入口读 pattern触发对应音色写渲染日志更新步进灯。scheduler()是 lookahead 调度器每 25ms 检查一次提前 0.1 秒把所有音符放入 Web Audio 的音频时钟。6. 可验证渲染机制从“能出声”到“可审计”6.1 渲染日志结构日志记录的核心信息是“什么音色、在哪一步、相对播放起点偏移多少秒”。例如 128 BPM 下的日志hat step0 offset0.0500s kick step0 offset0.0500s hat step1 offset0.1672s hat step2 offset0.2844s hat step3 offset0.4016s kick step4 offset0.5188s这里的 offset 是相对播放开始的音频时间。128 BPM 的 16 分音符间隔理论上是 60 / 128 / 4 0.1171875 秒四舍五入到 4 位就是 0.1172 秒。你可以肉眼或写脚本验证相邻 hat 的 offset 差是否稳定在 0.1172 附近。这个差值一旦出现明显波动就说明调度出现了 jitter 或丢步。这就是“可验证”的第一层价值你不是靠耳朵判断节奏稳不稳而是靠数据确认每一步是否按计划发生。6.2 确定性从随机噪声到固定种子上面的代码里噪声 buffer 用的是Math.random()这意味着每次播放 Hi-Hat 和 Snare 的频谱细节都不同。对于电子音乐的“律动验证”来说音符触发时间仍然是确定的但如果你希望连音色都完全可复现可以用固定种子伪随机函数替换Math.random()例如function mulberry32(seed) { return function () { seed | 0; seed seed 0x6D2B79F5 | 0; let t Math.imul(seed ^ seed 15, 1 | seed); t t Math.imul(t ^ t 7, 61 | t) ^ t; return ((t ^ t 14) 0) / 4294967296; }; } const rand mulberry32(42); function createNoiseBuffer() { const buffer audioCtx.createBuffer(1, audioCtx.sampleRate, audioCtx.sample
返回列表