Web で動くドラムマシンを作るとき、たいていは
.wavか.mp3のサンプル音をロードして再生します。動くのは動くのですが、サンプルファイルなしで「ドラムらしい音」を OscillatorNode と BiquadFilter だけで合成することができます。各ボイス 20 行、合計 60 行。サンプル不要なので fetch 待ちもなく、即起動で叩けます。16 ステップシーケンサ + サンプルゼロ合成の実装と、合成レシピを音響学的にどう設計したかの話です。
🥁 Demo: https://sen.ltd/portfolio/drum-machine/
📦 GitHub: https://github.com/sen-ltd/drum-machine
ドラムらしさの正体 — 3 つの要素
ドラム音を合成するときに見る要素は以下の 3 つ:
| 要素 | 何を決めるか | 実装でどうするか |
|---|---|---|
| エネルギーカーブ | アタックの鋭さ + 減衰の速さ |
GainNode.gain の setValueAtTime + exponentialRampToValueAtTime
|
| スペクトル | 「音の色」 — 鈍いか、鋭いか、金属的か |
OscillatorNode.type (sine/triangle/square) + BiquadFilter
|
| ピッチ動作 | アタックで高く、すぐ低くなる (Kick の本質) | frequency.exponentialRampToValueAtTime |
サンプル再生だと全部「録音されたとおり」しか出せませんが、合成だとこの 3 つを独立に調整できる。「もう少しアタックを鋭く」「胴体を厚く」みたいな調整がパラメータで効きます。
Kick — sine sweep + 急速 amp decay
実物のキックドラムは「コツン」というアタックの後に「ボーン」という胴体が残る音。これを 1 個の正弦波で合成するレシピ:
osc.frequency.setValueAtTime(150, time); // アタック (click 寄り)
osc.frequency.exponentialRampToValueAtTime(48, time + 0.05); // 50ms で急降下
ampEnv: 1.0 → 0.0001 で 450ms (exponential)
ポイントは:
- 150 Hz → 48 Hz の急降下 が「コツン → ボーン」を作る。150 Hz だけだと音程が立ちすぎ、48 Hz だけだと胴体しかない
- ピッチ降下は 50ms と速い (人間の耳は 30ms 以下のイベントを連続音として聞く)
- amp 減衰は 450ms ぐらいでちょうど良い。短すぎると「ピッ」、長すぎると「ブーン」が間延びする
- 波形は sine 一択。triangle や square だと倍音が乗って「電子音」感が出てキック感が薄れる
export function playKick(ctx, time, dest, gain = 1) {
const osc = ctx.createOscillator();
osc.type = "sine";
osc.frequency.setValueAtTime(150, time);
osc.frequency.exponentialRampToValueAtTime(48, time + 0.05);
const ampEnv = ctx.createGain();
ampEnv.gain.setValueAtTime(0, time);
ampEnv.gain.linearRampToValueAtTime(gain, time + 0.001);
ampEnv.gain.exponentialRampToValueAtTime(0.0001, time + 0.45);
osc.connect(ampEnv).connect(dest);
osc.start(time);
osc.stop(time + 0.5);
}
exponentialRampToValueAtTime は 0 に到達できない (内部的に対数領域で計算するため) のが地味なハマりどころ。0.0001 などの十分小さい値を指定するか、最後に setValueAtTime(0, end) を打って明示的に切る必要があります。
Snare — pitched body + bandpassed noise の二層
実物のスネアは ほぼノイズです。が、それだけだと「ザーッ」としか聞こえない。「鋭い音色」を立たせる pitched body を並列で混ぜることで「スネアらしさ」が出ます:
[ triangle 200Hz → 50ms decay ] ─┐
├─→ master
[ noise → bandpass 1.5kHz Q=0.6 → 180ms decay ] ─┘
合成レシピ:
// Body: 200Hz triangle、急減衰
const body = ctx.createOscillator();
body.type = "triangle";
body.frequency.setValueAtTime(200, time);
const bodyEnv = envelopedGain(ctx, time, 0.55, 0.05); // 50ms
body.connect(bodyEnv).connect(dest);
body.start(time);
body.stop(time + 0.07);
// Noise: white noise を bandpass、長めに減衰
const noise = ctx.createBufferSource();
noise.buffer = whiteNoiseBuffer; // pre-generated Float32Array
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.value = 1500;
filter.Q.value = 0.6;
const noiseEnv = envelopedGain(ctx, time, 0.6, 0.18); // 180ms
noise.connect(filter).connect(noiseEnv).connect(dest);
noise.start(time);
ポイント:
- bandpass の中心周波数 1500 Hz はスネアの「シャリ」が住む帯域。ここを外すと「ザザザ…」のノイズだけになる
- Q = 0.6 は「軽くフィルタリング」レベル。Q を上げすぎると音色が変わって金属的になる (鳴り物パーカッションっぽい)
- body の triangle は sine より倍音が乗って音色がカチッとする。snare 系では sine だと地味すぎる
- ホワイトノイズバッファは
ctx.createBuffer(1, len, sampleRate)で 1 回作ってMath.random() * 2 - 1で埋めて使い回し。毎回作ると hit ごとに数 KB のアロケーションが発生する
Hi-hat — square wave + 鋭い bandpass
ハイハットの正体は「金属同士のカチッ」です。これは合成的には 超高い倍音が密集して、急速に減衰する で表現できます:
osc.type = "square"; // square は奇数倍音が豊富 → 金属的
osc.frequency.setValueAtTime(7800, time);
filter.type = "bandpass";
filter.frequency.value = 7500; // 7-8kHz が cymbal の主要成分
filter.Q.value = 12; // 狭い帯域に絞る (高 Q = ピーキー)
decay: closed = 40ms / open = 320ms
ポイント:
- square wave (7800Hz の) は奇数倍音が豊富で、ハイハットの「カチッ」感が出る。sine だと「ピッ」になって金属感が消える
- bandpass Q=12 は高めの Q。これで 7500Hz 中心の狭帯域だけ通す → 金属的な「鳴り」になる
- closed (40ms) と open (320ms) は decay だけの違い。同じオシレータ + 同じフィルタで両方作れる。「open hat」を別ボイスとして用意する必要なし
export function playHat(ctx, time, dest, open = false, gain = 1) {
const osc = ctx.createOscillator();
osc.type = "square";
osc.frequency.setValueAtTime(7800, time);
const filter = ctx.createBiquadFilter();
filter.type = "bandpass";
filter.frequency.value = 7500;
filter.Q.value = 12;
const decay = open ? 0.32 : 0.04;
const env = envelopedGain(ctx, time, 0.45 * gain, decay);
osc.connect(filter).connect(env).connect(dest);
osc.start(time);
osc.stop(time + decay + 0.02);
}
Lookahead Scheduler — JS タイマーが信用できない問題
ここまでが「音作り」、次は 正確な timing で打つ 仕組み。
ナイーブな実装 setInterval(playStep, 60_000 / bpm / 4) は 必ずズレます。理由は JS の event loop が GC や layout で 10-50ms 平気で詰まるから。それを setInterval のコールバックで触ると音もそのままズレる。
正解は Chris Wilson の "A Tale of Two Clocks" パターン:
function tick() {
while (state.nextStepTime < audioCtx.currentTime + 0.1) {
onStep({ step: state.step, time: state.nextStepTime }); // ステップ予約
state.nextStepTime += secondsPerStep();
state.step = (state.step + 1) % stepsPerBar;
}
}
setInterval(tick, 25); // 25ms ごとに tick(), 100ms ahead で予約
仕組み:
-
audioCtx.currentTimeは audio スレッドが renders した正確な時刻 - ステップの予約は
osc.start(time)で 未来の時刻指定 が可能 - JS タイマーは「予約をするだけ」、実際の発音は audio スレッドが render する
- JS タイマーが 50ms 詰まっても、すでに 100ms 先まで予約済みなので発音は遅れない
これで「JS の timing は信頼しない、audio クロックだけが真」という設計になります。
Chris Wilson 方式は何が嬉しいか
setTimeout(playStep, 100) ではダメな理由がもう 1 つあって、OscillatorNode.start() を setTimeout の中から呼ぶと そこからオーディオ生成が始まる。 hit のタイミングが GC や layout の influence をモロに受ける。
これに対し osc.start(time) で未来の時刻を指定すれば、audio スレッドはそれを sample-accurate でレンダーします。50 ms の jitter があっても、tick が動く限り次の 100ms 分は予約済みなので 耳にはズレが届かない。
テスト — 偽 AudioContext で完全カバー
シンセシス部分 (kick が kick らしい音か) はもちろん耳テスト。ですが scheduler は決定論的に test できる:
function makeFakes() {
const audio = { currentTime: 0 }; // 偽の audio clock
const intervals = [];
const setIntervalFn = (fn, ms) => {
intervals.push({ fn, ms, cleared: false });
return intervals.length - 1;
};
const advance = (sec) => {
audio.currentTime += sec;
for (const slot of intervals) if (!slot.cleared) slot.fn();
};
return { audio, setIntervalFn, advance, /* ... */ };
}
test("at 120 BPM, 16 steps per bar = 0.125 s per step", () => {
const fakes = makeFakes();
const queued = [];
const sched = createScheduler({
audioCtx: fakes.audio,
bpm: 120, stepsPerBar: 16, beatsPerBar: 4,
onStep: ({ time }) => queued.push(time),
setIntervalFn: fakes.setIntervalFn,
clearIntervalFn: fakes.clearIntervalFn,
});
sched.start();
fakes.advance(1.0);
// 連続するステップの間隔がきっかり 0.125 秒
for (let i = 1; i < queued.length; i++) {
assert.ok(Math.abs(queued[i] - queued[i - 1] - 0.125) < 1e-9);
}
});
scheduler は audioCtx と setIntervalFn を 依存性注入で受け取るので、Node の node:test で AudioContext なしに完全に検証できる。17 ケースで 0.07 秒。
まとめ
- Kick = sine 150→48 Hz exponential sweep、amp 450ms decay。三要素のうち pitch sweep が一番効く
- Snare = triangle 200Hz body (50ms) + bandpass 1.5kHz Q=0.6 noise (180ms) を並列で混ぜる。ノイズだけでは「スネア感」がない
- Hi-hat = square 7800Hz + bandpass 7500Hz Q=12 を closed/open で decay だけ違える。1 オシレータ + 1 フィルタで両方表現
-
exponentialRampToValueAtTimeは 0 に届かない。0.0001を使う - Lookahead scheduler で JS タイマーの jitter を吸収。25ms tick + 100ms ahead で sample-accurate
-
依存性注入で AudioContext を fake にすれば、scheduler のテストは Node
node:testで完結
コード全文 — drums.js (合成 + パターン)、scheduler.js (Chris-Wilson lookahead)、tests/ (17 ケース)。MIT。
Web Audio 連作 3 件目: #206 metronome (timing) → #212 pitch-detector (analysis) → 今回 (synthesis)。
