单个 HTML 文件里塞进一个 techno 机器,听起来像演示项目,实际上它把两件事压缩到很小的体积里:一是用代码实时生成音乐节奏,二是让每一次视觉渲染结果可以被验证。这里的关键是 verifiable renders。很多网页动画看起来类似,但同一段代码在不同时间、不同机器上跑出来的画面很难对账;如果换成“给定种子、给定帧号,画面就应当可复现”的设计思路,那么这个 HTML 文件就从一个一次性 demo 变成了一个可测试、可对比、可给别人复现的运行单元。下面的完整实现会围绕这个思路,从零写一个单文件 techno machine:Web Audio 承担声音,Canvas 承担画面,确定性随机数承担可复现性,最后附完整代码、验证方法和排错清单。
如果你平时用 HTML、CSS、JavaScript 做页面,但还没有把 Web Audio API 和 Canvas 组合起来做过“生成式小作品”,这篇文章非常适合当入门练习。读完以后,你会得到一个可以直接双击打开的音画 demo,并且能解释清楚“为什么两次渲染结果可以保持一致”。
1. 先拆解“单文件 techno machine”和“可验证渲染”
1.1 techno machine 在这个项目里指什么
这里没有一份完整的产品规格说明书,但从标题可以明确技术对象:在单个 HTML 文件中实现一台 techno 机器。techno machine 可以理解为一个小型音乐生成器,它循环播放 techno 风格的节奏,同时用视觉动画把节拍画出来。
技术栈并不神秘:
- Web Audio API 负责实时合成鼓点、踩镲和低音贝斯。
- Canvas 2D API 负责绘制画面,用圆环、粒子、旋转线条等元素表现节奏。
- 一段确定性随机数算法代替
Math.random(),保证同一个种子下每次生成的画面可复现。
真实项目里,这种单文件 demo 很常见。比如在技术社区用 Show HN 形式分享作品时,单个 HTML 文件是最容易传播的形态:不需要安装依赖,不需要构建,别人把文件保存下来就能看。
1.2 为什么坚持用单个 HTML 文件
把 CSS、HTML、JavaScript 全部内联在一起,看起来像“偷懒”,实际上有几个具体好处:
- 零依赖、零构建。文件不依赖 npm 包、CDN 或网络请求,保存到本地双击就能运行。
- 分享成本低。一个
.html文件就是完整项目,发文件、放进压缩包、托管到任意静态页面都可以。 - 代码可审计。所有逻辑都在同一个文件里,别人能直接看到你在用什么算法、有没有联网、有没有隐藏请求。
- 版本对比方便。你可以把某一天的版本复制成
techno-20250101.html,过段时间再对比行为差异。
代价也很明确:单文件不适合大型项目。当代码超过一千行,或者需要多人协作时,模块化是必须的。单文件更适合“作品级 demo”或“教学示例”,这也是这个项目的合理定位。
1.3 verifiable renders 到底验证什么
verifiable renders 翻译过来是“可验证渲染”,核心含义是:相同输入条件下,渲染结果应当可复现、可对比、可审计。
具体到本项目,输入条件包括:
- seed 字符串,用来派生调色板、粒子位置等随机参数。
- BPM 值,决定步进速度和音符间隔。
- frame 编号,决定当前画面处于动画时间轴的第几帧。
- 固定画布尺寸和固定逻辑帧率。
输出则是一张确定的 Canvas 画面。你可以对画面计算一个哈希值,比如a1b2c3d4e5f67890,把这个值和 frame 编号一起展示在页面上。两个浏览器如果得到相同的哈希,说明它们在相同输入下渲染结果一致;如果不一致,说明存在渲染差异。
这种方式比“肉眼看着差不多”更有说服力,也方便做回归测试:以后改了代码,如果相同 seed、相同 frame 的哈希变了,你知道画面行为发生了变化。
1.4 适用读者和前置知识
这篇文章适合下面几类读者:
- 会写基础 HTML/CSS/JavaScript,想动手做一个生成式音画 demo。
- 了解 Canvas 基本绘图,但没做过音频调度。
- 对 Web Audio API 感兴趣,想要一个最小的合成器示例。
- 想把“动画可复现”这个设计思路用到自己的项目里。
前置知识不需要太多。你能读懂基本的 JavaScript 函数、事件监听和requestAnimationFrame就够了。Web Audio 和确定性随机数的细节,文章会逐步解释。
2. 搭建单文件骨架:页面、样式、画布三件事
2.1 页面结构:一个 canvas 加一组控制项
单文件项目也要有清晰的页面结构。本项目只做四件事:
- 一个输入框,用来填写 seed。
- 一个数字输入框,用来调整 BPM。
- 一组按钮,控制开始、停止、重置、捕获哈希。
- 一个 Canvas 画布,用来渲染画面。
- 一小块文字区域,用来显示当前帧号和像素哈希。
HTML 结构看起来是这样:
<div class="controls"> <label>Seed <input id="seed" type="text" value="default-seed"></label> <label>BPM <input id="bpm" type="number" min="80" max="160" value="124"></label> <button id="startBtn">Start</button> <button id="stopBtn">Stop</button> <button id="resetBtn">Reset</button> <button id="captureBtn">Capture Hash</button> </div> <div id="meta">ready</div> <canvas id="viz" width="960" height="540"></canvas>注意这里把控制项放在 Canvas 上方,文字显示放在中间,这样在任何屏幕宽度下都不容易被遮挡。
2.2 固定画布尺寸,别让 CSS 随意改变内部像素
Canvas 有一点很关键:<canvas width="960" height="540">是画布的“内部像素尺寸”,CSS 里的width: 100%只影响它显示出来的大小,不会改变内部绘图分辨率。
本项目选择固定960x540作为内部尺寸,有三个原因:
- 可复现性好。内部像素固定,
getImageData拿到的像素矩阵大小固定,哈希计算稳定。 - 显示自适应。CSS 可以把画布缩放到手机屏幕,不影响内部逻辑。
- 性能可控。960x540 大约 200 万像素,在普通电脑上 Canvas 2D 绘制足够流畅。
在样式中设置:
canvas { width: 100%; max-width: 960px; height: auto; background: #000; }这样画布会按比例缩放,但在内部始终以 960x540 的坐标系统绘图。
2.3 脚本组织方式:先定模块,再写代码
单文件不代表可以把代码乱写。为了后面好解释,JavaScript 按模块分成几个区域:
- 工具函数:字符串哈希、确定性随机数生成器、种子组合。
- 音频模块:创建 AudioContext、合成 kick/hat/bass、调度音符。
- 视觉模块:逻辑帧推进、调色板生成、Canvas 绘制。
- 验证模块:从 Canvas 像素计算哈希。
- 事件绑定:按钮点击、键盘空格、seed 变化。
- 初始化:生成种子、画第一帧、启动渲染循环。
这个顺序也是文章后面的讲解顺序。
2.4 最小骨架代码
先给一个最小骨架,完整代码在第 6 章:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>Techno Machine</title> <style> /* 页面样式 */ </style> </head> <body> <!-- 控制区和 canvas --> <script> "use strict"; // 分区编写 JavaScript </script> </body> </html>这个骨架的核心意义是:让所有代码都在一个文件里,同时保持逻辑清晰。“内联”不等于“混乱”,只要把功能边界分开,单文件也可以有良好的可读性。
3. 用 Web Audio API 写一个最小 techno 音序器
3.1 AudioContext 的创建与恢复
Web Audio API 的核心对象是AudioContext。它管理一个音频图,所有声音节点都连接在这个图上。现代浏览器为了用户体验,通常不允许页面加载后立刻自动播放声音,必须由用户手势触发。
所以项目里不在一开始创建 AudioContext,而是在点击 Start 按钮时创建:
function createAudioGraph() { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); masterGain = audioCtx.createGain(); masterGain.gain.value = 0.7; masterGain.connect(audioCtx.destination); }创建之后还要调用audioCtx.resume()。因为某些浏览器在页面启动阶段创建 AudioContext,它的状态会停留在suspended,需要用户手势配合恢复。
这里有一个常被忽略的细节:resume()返回一个 Promise。如果你在异步回调里继续调度音符,要确保 Promise 已经 resolve。
3.2 三种基础音色:Kick、Hi-hat、Bass
techno 的基础节奏通常由 Kick、Hi-hat、Bass 组成。用 Web Audio 合成这些音色并不复杂。
Kick 使用一个正弦波振荡器,频率从 160Hz 快速下滑到 46Hz,同时音量快速衰减。这样听起来就是“嘭”一声短促的低鼓:
function playKick(time) { const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.type = "sine"; osc.frequency.setValueAtTime(160, time); osc.frequency.exponentialRampToValueAtTime(46, time + 0.11); gain.gain.setValueAtTime(0.9, time); gain.gain.exponentialRampToValueAtTime(0.001, time + 0.32); osc.connect(gain); gain.connect(masterGain); osc.start(time); osc.stop(time + 0.35); }频率从高到低快速下滑,是 Kick 音色的关键。如果不加频率变化,只有音量衰减,听起来更像一个闷响,缺少打击感。
Hi-hat 则使用一段噪声缓冲。