使用 Web Audio API 打造一
2026-01-08
使用 Web Audio API 打造一个 16 步进的鼓机/音序器。界面应为一个 4x16 的网格,分别对应 Kick, Snare, Hi-hat, Clap 四种打击乐器。用户点击网格点亮节点以编排节奏。功能要求:1. 播放/停止控制及 BPM 速度调节;2. 每一列在播放扫过时高亮显示;3. 简单的音量和混响(Reverb)控制旋律。视觉风格参考 Roland TR-808 或现代 DAW 软件,霓虹配色,夜间模式。
思考过程
**Envisioning the Sequencer**
I'm starting to build a mental map of this drum machine. First, the core – a 16-step sequencer using the Web Audio API. I'm focusing on the essential elements: a grid layout, four instrument rows, and a mechanism for triggering sounds. Currently considering how to handle instrument selection and the overall user interface for the grid.
**Outlining the Structure**
\n\n
I've decided to prioritize a synthesis-based approach for the sounds to keep things simple and avoid external dependencies. My focus has shifted towards the JavaScript implementation. I'm planning to build the core audio engine first, with oscillators and noise generators for the kick, snare, and hi-hat. Thinking about how to synthesize a "Clap" sound effectively using noise or a similar method.
**Analyzing the Requirements**
\n\n
My primary focus is now on the core features, specifically the play/stop and BPM controls, as well as the visual feedback. I'm leaning towards the use of a state array and a loop function that utilizes the AudioContext's timing capabilities to schedule the sounds. I'm strategizing how to implement the neon-style UI elements to give it that authentic TR-808 feel. The use of synthesized impulse responses for the reverb has become a top priority.
**Formulating the Plan**
\n\n
My current thinking is focused on a detailed, step-by-step implementation. I've broken the project down, from sound generation using synthesis (kick, snare, hi-hat) to UI structure and visual styling using neon colors for that TR-808 feel. The JavaScript scheduler will utilize the "Tale of Two Clocks" approach with `requestAnimationFrame` for tight timing. Generating a synthetic impulse response for reverb is the last key piece I need to solidify.
**Synthesizing the Sounds**
\n\n
My current thinking centers on the crucial step of creating the actual sounds. I'm focusing on synthesizing the Kick, Snare, Hi-Hat, and Clap using oscillators, noise, and envelope generators. I've broken down each sound to define the ideal wave shapes and filter settings. A high-priority item is building the synth-generated impulse response for the reverb, and the use of the "Tale of Two Clocks" scheduling implementation.
**Synthesizing and Structuring**
\n\n
My current thinking centers on finalizing the sound design, particularly the clap sound. I'm streamlining the synthesis approach, opting for simplicity. Now my focus is shifting towards the overall architecture, mainly the UI and the control elements, while considering how I'll utilize CSS and JS to give it the desired neon-style TR-808 visual aesthetic. I've broken down the design into smaller, more manageable blocks.
这是一个完整的单文件解决方案。为了确保您可以直接运行而无需下载外部音频样本文件,我使用了 Web Audio API 的振荡器(Oscillators)和噪声缓冲区(Noise Buffers)来**实时合成**所有的鼓声(Kick, Snare, Hi-hat, Clap)以及混响效果。
### 功能特点:
1. **声音合成**:完全基于代码生成,无需加载 MP3/WAV。
2. **精确计时**:使用 Web Audio API 的 `currentTime` 和调度预读(Lookahead)机制,保证节奏稳如磐石,不会因为 UI 卡顿而跑调。
3. **效果器**:包含主音量(Master Volume)和 卷积混响(Convolution Reverb)。
4. **视觉效果**:霓虹 Cyberpunk/TR-808 风格,播放时高亮当前列。
您可以将以下代码保存为 `.html` 文件,然后在现代浏览器(Chrome, Firefox, Edge, Safari)中打开。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neon 808 - Web Audio Sequencer</title>
<style>
:root {
--bg-color: #121214;
--panel-color: #1e1e24;
--text-color: #e0e0e0;
--accent-kick: #ff0055; /* Neon Pink */
--accent-snare: #00ffe5; /* Cyan */
--accent-hat: #ffdd00; /* Yellow */
--accent-clap: #bd00ff; /* Purple */
--glow-strength: 0 0 10px;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
user-select: none;
}
h1 {
font-weight: 200;
letter-spacing: 4px;
margin-bottom: 20px;
text-shadow: 0 0 10px rgba(255,255,255,0.2);
}
.machine-container {
background-color: var(--panel-color);
padding: 30px;
border-radius: 12px;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
border: 1px solid #333;
}
/* Controls Area */
.controls {
display: flex;
gap: 20px;
margin-bottom: 25px;
align-items: center;
background: #18181b;
padding: 15px;
border-radius: 8px;
border-bottom: 2px solid #333;
}
button.btn-main {
background: #333;
color: #fff;
border: 1px solid #555;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
text-transform: uppercase;
letter-spacing: 1px;
}
button.btn-main.active {
background: #00ff66;
color: #000;
box-shadow: 0 0 15px #00ff66;
border-color: #00ff66;
}
.control-group {
display: flex;
flex-direction: column;
gap: 5px;
}
label {
font-size: 10px;
text-transform: uppercase;
color: #888;
letter-spacing: 1px;
}
input[type=range] {
width: 100px;
accent-color: var(--accent-snare);
cursor: pointer;
}
/* Sequencer Grid */
.grid {
display: grid;
grid-template-columns: 80px repeat(16, 1fr);
gap: 6px;
}
.track-label {
display: flex;
align-items: center;
font-size: 12px;
font-weight: bold;
color: #888;
text-transform: uppercase;
letter-spacing: 1px;
}
.step {
width: 30px;
height: 40px;
background-color: #2a2a30;
border: 1px solid #333;
border-radius: 4px;
cursor: pointer;
transition: all 0.1s ease;
}
.step:hover {
background-color: #3a3a40;
}
/* Beat Markers (every 4 steps) */
.step:nth-child(4n + 2) {
margin-right: 4px;
}
/* Active States for Instruments */
.step.active[data-row="kick"] { background-color: var(--accent-kick); box-shadow: var(--glow-strength) var(--accent-kick); border-color: var(--accent-kick); }
.step.active[data-row="snare"] { background-color: var(--accent-snare); box-shadow: var(--glow-strength) var(--accent-snare); border-color: var(--accent-snare); }
.step.active[data-row="hihat"] { background-color: var(--accent-hat); box-shadow: var(--glow-strength) var(--accent-hat); border-color: var(--accent-hat); }
.step.active[data-row="clap"] { background-color: var(--accent-clap); box-shadow: var(--glow-strength) var(--accent-clap); border-color: var(--accent-clap); }
/* Playing Head Highlight */
.step.playing {
filter: brightness(1.8);
transform: scale(1.05);
border: 1px solid white;
}
/* Init Overlay */
#overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.85);
display: flex;
justify-content: center;
align-items: center;
z-index: 100;
backdrop-filter: blur(5px);
flex-direction: column;
}
.start-btn {
font-size: 24px;
padding: 20px 40px;
background: transparent;
color: var(--accent-snare);
border: 2px solid var(--accent-snare);
border-radius: 50px;
cursor: pointer;
box-shadow: 0 0 20px var(--accent-snare);
transition: 0.3s;
}
.start-btn:hover {
background: var(--accent-snare);
color: #000;
}
</style>
</head>
<body>
<div id="overlay">
<button class="start-btn" id="initBtn">CLICK TO START</button>
<p style="margin-top:20px; color:#666;">Audio Context requires user interaction</p>
</div>
<h1>NEON 808</h1>
<div class="machine-container">
<div class="controls">
<button id="playBtn" class="btn-main">Play</button>
<div class="control-group">
<label for="bpm">BPM <span id="bpmVal">120</span></label>
<input type="range" id="bpm" min="60" max="200" value="120">
</div>
<div class="control-group">
<label for="volume">Volume</label>
<input type="range" id="volume" min="0" max="1" step="0.01" value="0.8">
</div>
<div class="control-group">
<label for="reverb">Reverb</label>
<input type="range" id="reverb" min="0" max="1" step="0.01" value="0.3">
</div>
<button id="clearBtn" class="btn-main" style="margin-left:auto; font-size:12px; background: #333;">Clear</button>
</div>
<div class="grid" id="sequencerGrid">
<!-- Grid generated by JS -->
</div>
</div>
<script>
/**
* Web Audio API Drum Machine
* 1. Synthesis Engine (create sounds from math)
* 2. Scheduler (Timing)
* 3. UI Handling
*/
// Audio Context Global Variables
let audioCtx;
let masterGain;
let reverbNode;
let reverbGain;
// Sequencer State
const steps = 16;
const instruments = ['kick', 'snare', 'hihat', 'clap'];
// 4 rows, 16 cols. 0 = off, 1 = on
let gridData = [
new Array(16).fill(0), // Kick
new Array(16).fill(0), // Snare
new Array(16).fill(0), // Hihat
new Array(16).fill(0) // Clap
];
// Timing Variables
let isPlaying = false;
let currentStep = 0;
let tempo = 120.0;
let nextNoteTime = 0.0;
let timerID;
const lookahead = 25.0; // ms
const scheduleAheadTime = 0.1; // seconds
// --- 1. INITIALIZATION ---
async function initAudio() {
const AudioContext = window.AudioContext || window.webkitAudioContext;
audioCtx = new AudioContext();
// Master Output
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.8;
// Reverb Chain
reverbNode = audioCtx.createConvolver();
// Generate a synthetic impulse response for reverb
reverbNode.buffer = createImpulseResponse(2.0, 2.0);
reverbGain = audioCtx.createGain();
reverbGain.gain.value = 0.3;
// Connect: Instrument -> Master -> Destination
// Instrument -> ReverbGain -> ReverbNode -> Master
masterGain.connect(audioCtx.destination);
reverbGain.connect(reverbNode);
reverbNode.connect(masterGain);
}
// --- 2. SOUND SYNTHESIS ---
// Helper: Create White Noise Buffer
function createNoiseBuffer() {
const bufferSize = audioCtx.sampleRate * 2; // 2 seconds
const buffer = audioCtx.createBuffer(1, bufferSize, audioCtx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
return buffer;
}
// Helper: Synthetic Reverb Impulse Response
function createImpulseResponse(duration, decay) {
const length = audioCtx.sampleRate * duration;
const impulse = audioCtx.createBuffer(2, length, audioCtx.sampleRate);
const left = impulse.getChannelData(0);
const right = impulse.getChannelData(1);
for (let i = 0; i < length; i++) {
const n = i / length;
// Simple exponential decay noise
left[i] = (Math.random() * 2 - 1) * Math.pow(1 - n, decay);
right[i] = (Math.random() * 2 - 1) * Math.pow(1 - n, decay);
}
return impulse;
}
let noiseBuffer; // initialized later
function playKick(time) {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(masterGain); // Kick usually dry, maybe a little reverb
osc.frequency.setValueAtTime(150, time);
osc.frequency.exponentialRampToValueAtTime(0.01, time + 0.5);
gain.gain.setValueAtTime(1, time);
gain.gain.exponentialRampToValueAtTime(0.01, time + 0.5);
osc.start(time);
osc.stop(time + 0.5);
}
function playSnare(time) {
// Tone
const osc = audioCtx.createOscillator();
const oscGain = audioCtx.createGain();
osc.type = 'triangle';
osc.connect(oscGain);
oscGain.connect(masterGain);
oscGain.connect(reverbGain);
osc.frequency.setValueAtTime(250, time);
oscGain.gain.setValueAtTime(0.5, time);
oscGain.gain.exponentialRampToValueAtTime(0.01, time + 0.2);
osc.start(time);
osc.stop(time + 0.2);
// Noise Snap
const noise = audioCtx.createBufferSource();
noise.buffer = noiseBuffer;
const noiseFilter = audioCtx.createBiquadFilter();
noiseFilter.type = 'highpass';
noiseFilter.frequency.value = 1000;
const noiseGain = audioCtx.createGain();
noise.connect(noiseFilter);
noiseFilter.connect(noiseGain);
noiseGain.connect(masterGain);
noiseGain.connect(reverbGain);
noiseGain.gain.setValueAtTime(0.8, time);
noiseGain.gain.exponentialRampToValueAtTime(0.01, time + 0.2);
noise.start(time);
noise.stop(time + 0.2);
}
function playHiHat(time) {
// High frequency noise
const src = audioCtx.createBufferSource();
src.buffer = noiseBuffer;
// Bandpass filter for metallic sound
const filter = audioCtx.createBiquadFilter();
filter.type = 'highpass';
filter.frequency.value = 7000;
const gain = audioCtx.createGain();
src.connect(filter);
filter.connect(gain);
gain.connect(masterGain);
gain.connect(reverbGain);
gain.gain.setValueAtTime(0.6, time);
// Very short decay
gain.gain.exponentialRampToValueAtTime(0.01, time + 0.05);
src.start(time);
src.stop(time + 0.05);
}
function playClap(time) {
const src = audioCtx.createBufferSource();
src.buffer = noiseBuffer;
const filter = audioCtx.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = 1500;
filter.Q.value = 1;
const gain = audioCtx.createGain();
src.connect(filter);
filter.connect(gain);
gain.connect(masterGain);
gain.connect(reverbGain);
// Clap envelope (slightly longer attack feel)
gain.gain.setValueAtTime(0, time);
gain.gain.linearRampToValueAtTime(0.8, time + 0.01);
gain.gain.exponentialRampToValueAtTime(0.01, time + 0.3);
src.start(time);
src.stop(time + 0.3);
}
function playSound(instrumentIndex, time) {
switch(instrumentIndex) {
case 0: playKick(time); break;
case 1: playSnare(time); break;
case 2: playHiHat(time); break;
case 3: playClap(time); break;
}
}
// --- 3. SCHEDULING LOGIC ---
function nextNote() {
const secondsPerBeat = 60.0 / tempo;
// 16th notes = 0.25 of a beat
nextNoteTime += 0.25 * secondsPerBeat;
currentStep = (currentStep + 1) % steps;
}
function scheduleNote(stepNumber, time) {
// Push visualization update to draw queue
// We use 'drawSteps' so visuals match audio time as close as possible
// (Strictly speaking, visuals should be scheduled via requestAnimationFrame based on time)
// Check grid data
for (let i = 0; i < 4; i++) {
if (gridData[i][stepNumber] === 1) {
playSound(i, time);
}
}
}
function scheduler() {
// While there are notes that will need to play before the next interval,
// schedule them and advance the pointer.
while (nextNoteTime < audioCtx.currentTime + scheduleAheadTime) {
scheduleNote(currentStep, nextNoteTime);
nextNote();
}
if (isPlaying) {
timerID = requestAnimationFrame(scheduler);
}
}
// --- 4. UI & INTERACTION ---
// Render Grid
const gridContainer = document.getElementById('sequencerGrid');
instruments.forEach((inst, rowIndex) => {
// Label
const label = document.createElement('div');
label.className = 'track-label';
label.innerText = inst;
gridContainer.appendChild(label);
// Steps
for (let colIndex = 0; colIndex < 16; colIndex++) {
const btn = document.createElement('div');
btn.className = 'step';
btn.dataset.row = inst;
btn.dataset.rowIndex = rowIndex;
btn.dataset.col = colIndex;
// Add default pattern (optional)
if ((rowIndex === 0 && colIndex % 4 === 0) || // Kick on beat
(rowIndex === 2 && colIndex % 2 === 0)) { // Hihats
// Don't set active class yet, wait for init
// We just preset data
gridData[rowIndex][colIndex] = 1;
btn.classList.add('active');
}
btn.addEventListener('click', () => {
// Toggle
const isActive = btn.classList.toggle('active');
gridData[rowIndex][colIndex] = isActive ? 1 : 0;
});
gridContainer.appendChild(btn);
}
});
// Visual Loop for Highlighting Columns
let lastVisualStep = -1;
function updateVisuals() {
if (!isPlaying) return;
// Calculate which 16th note we are currently hearing
// This is an estimation for visuals.
// For perfect sync we would queue DOM updates in scheduleNote,
// but checking time vs currentStep is simpler for this scope.
// Note: Since currentStep is advanced in the scheduler (future),
// we want to visualize the step corresponding to audioCtx.currentTime
// This simple approach just highlights the variable 'currentStep'.
// Because scheduleAheadTime is small (0.1s), the visual offset is negligible.
if (currentStep !== lastVisualStep) {
// Remove old playing class
document.querySelectorAll('.step.playing').forEach(el => el.classList.remove('playing'));
// Add new playing class to current column (minus 1 because scheduler advances early)
let visualStep = (currentStep === 0) ? 15 : currentStep - 1;
document.querySelectorAll(`.step[data-col="${visualStep}"]`).forEach(el => {
el.classList.add('playing');
});
lastVisualStep = currentStep;
}
if (isPlaying) {
requestAnimationFrame(updateVisuals);
}
}
// --- EVENT LISTENERS ---
const playBtn = document.getElementById('playBtn');
const bpmInput = document.getElementById('bpm');
const bpmVal = document.getElementById('bpmVal');
const volInput = document.getElementById('volume');
const revInput = document.getElementById('reverb');
const initBtn = document.getElementById('initBtn');
const overlay = document.getElementById('overlay');
const clearBtn = document.getElementById('clearBtn');
// Start Audio Context
initBtn.addEventListener('click', async () => {
await initAudio();
noiseBuffer = createNoiseBuffer();
overlay.style.opacity = 0;
setTimeout(() => overlay.remove(), 500);
});
// Play / Stop
playBtn.addEventListener('click', () => {
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
isPlaying = !isPlaying;
if (isPlaying) {
currentStep = 0;
nextNoteTime = audioCtx.currentTime;
scheduler();
updateVisuals();
playBtn.innerText = "Stop";
playBtn.classList.add('active');
} else {
window.cancelAnimationFrame(timerID);
playBtn.innerText = "Play";
playBtn.classList.remove('active');
// clear highlights
document.querySelectorAll('.step.playing').forEach(el => el.classList.remove('playing'));
}
});
// Controls
bpmInput.addEventListener('input', (e) => {
tempo = parseFloat(e.target.value);
bpmVal.innerText = tempo;
});
volInput.addEventListener('input', (e) => {
if(masterGain) masterGain.gain.value = parseFloat(e.target.value);
});
revInput.addEventListener('input', (e) => {
if(reverbGain) reverbGain.gain.value = parseFloat(e.target.value);
});
clearBtn.addEventListener('click', () => {
// Reset Data
for(let r=0; r<4; r++) {
gridData[r].fill(0);
}
// Remove Visuals
document.querySelectorAll('.step.active').forEach(el => el.classList.remove('active'));
});
</script>
</body>
</html>
```
### 核心实现原理:
1. **HTML 结构**:
* 使用 `grid` 布局创建 4x16 的网格。
* 利用 data attributes (`data-row`, `data-col`) 来管理状态,便于 JS 快速查找。
2. **CSS 样式**:
* **配色**:深色背景配合高饱和度的霓虹色(Pink, Cyan, Yellow, Purple)模拟 TR-808 的复古未来感。
* **光效**:使用 `box-shadow` 和 `text-shadow` 制作发光效果。
* **动画**:`.step.playing` 类使用了 `scale` 和 `brightness` 滤镜,让扫过的列有律动感。
3. **JavaScript (Web Audio API)**:
* **音频上下文 (AudioContext)**:是所有音频操作的核心。
* **声音合成 (Synthesis)**:
* **Kick (底鼓)**:使用正弦波 (`Oscillator`),快速向下扫描频率(从 150Hz 降到 0.01Hz),模拟鼓皮的张力释放。
* **Snare (军鼓)**:混合三角波(提供音调)和白噪声(提供沙沙声),并通过高通滤波器模拟响弦的声音。
* **Hi-hat (踩镲)**:高通滤波后的高频噪声,包络(Envelope)极短,模拟金属撞击声。
* **Clap (拍手)**:带通滤波后的噪声,略微拉长 Attack 时间。
* **调度器 (The Scheduler)**:
* 这是节奏精准的关键。代码参考了经典的 "A Tale of Two Clocks" 文章。JS 的 `setTimeout` 或 `requestAnimationFrame` 不够精确,所以我们只用它们来“向前看”一小段时间(Lookahead),然后使用 `audioCtx.currentTime` 告诉浏览器在未来的确切时间点播放声音。
* **效果器**:
* **混响 (Convolver)**:我编写了一个 `createImpulseResponse` 函数,通过算法生成一个随机的衰减噪声缓冲区,作为卷积混响的冲激响应。这样无需下载外部 `.wav` 文件就能获得空间感。
### 如何使用:
1. 点击页面中央的 "CLICK TO START" 初始化音频引擎(浏览器策略要求)。
2. 点击网格上的方块点亮节奏点(预设了一些简单的节奏)。
3. 点击 **Play** 开始播放。
4. 拖动 **BPM** 滑块改变速度,拖动 **Reverb** 增加空间感。