Web Audioで音の遊びを作る:四つの音から始めるリズムマシン
遊びをつくる執筆にClaudeを使用しています。

「ブラウザで音を鳴らしたい」という出発点は小さい。しかし、いざ実装してみると「なぜかテンポがだんだんずれる」「止めたはずの音が残る」という壁に当たりやすい。この記事では、Web Audio APIのOscillatorNodeで音を一つ作るところから、16ステップのループをタイミングよく回す仕組みまでを順番に追う。外部ライブラリは使わず、ブラウザ標準のAPIだけで動く構成を説明する。コード例は動作原理を伝えるための説明用であり、本番品質のコードとして検証したものではない。本文はClaudeが執筆し、編集部が内容を確認している。
この記事の目次
なぜ「時刻を予約する」必要があるのか
リズムマシンを作るとき、最初に思いつく方法は「setIntervalで一定間隔ごとに音を鳴らす」だ。しかし、ブラウザのsetIntervalはタブのバックグラウンド状態やガベージコレクションの影響を受けて実行タイミングがずれる。音楽用途では数十ミリ秒のずれでもリズムが崩れたように聴こえる。
Web Audio APIはこの問題に対して、AudioContext.currentTimeという専用の高精度クロックを提供している。このクロックはオーディオレンダリングスレッドが管理するため、UIスレッドの遅延に左右されない。開発者がやることは「何秒後に鳴らす」という時刻をあらかじめAPIに渡すことだけだ。Web Audio API仕様では、OscillatorNodeを含むAudioScheduledSourceNodeのstart(when)・stop(when)メソッドで、この時刻を秒単位で指定できると定められている。
currentTimeの単位は秒(浮動小数点)。AudioContextを生成した瞬間を0として単調増加する。start(0)は「今すぐ」を意味する。新しく生成されたAudioContextは仕様上、常にsuspended(一時停止)状態から始まる。
OscillatorNodeで音を一つ作る
まず音を鳴らすだけの最小構成から始める。OscillatorNodeはサイン波・矩形波・のこぎり波などを内部で生成するノードで、外部の音声ファイルを用意しなくても発音できる。仕様上のtypeには 'sine'(柔らかい)'square'(硬い)'sawtooth'(倍音が豊か)'triangle' が用意されている。
// AudioContextはユーザー操作(クリック等)のあとに生成または resume() する
const ctx = new AudioContext();
// type はデフォルト 'square'。呼び出し側から波形を切り替えられるようにする
function playTone(frequency, startTime, duration, type = 'square') {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type; // trackConfig の波形設定がここに反映される
osc.frequency.value = frequency;
// 音の終わりをなめらかにしてクリックノイズを防ぐ
gain.gain.setValueAtTime(1, startTime);
gain.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(startTime);
osc.stop(startTime + duration + 0.01); // フェード後に確実に停止
}OscillatorNodeは一度stopを呼ぶと再利用できないため、音を鳴らすたびに新しいインスタンスを作る。stop後のノードはガベージコレクタが回収するので、接続を切っておく限りメモリが際限なく増え続けることはない。ただしループ間隔に対して適切な個数にとどめる。
四つの音で音色の差をつけるには、周波数と波形タイプを組み合わせる。低音(約80〜120Hz)にsineを使うとキック的な柔らかさが出る。中高音(200〜800Hz)にsquareを使うとスネアやハイハットに近い硬さになる。sawtoothは倍音が多く、ベース的な存在感を加えられる。
ルックアヘッドスケジューラーでテンポを保つ
音を一つ作れたら、次は「一定のテンポで繰り返す」仕組みが必要になる。setIntervalの問題を回避するパターンとして、MDNの公式ドキュメントでも紹介されているのがルックアヘッドスケジューリングだ。「少し先の未来まで音を予約してしまう」という考え方で、setTimeoutの誤差を先読み時間の中に吸収させる。
具体的には、setTimeoutを短い間隔(例:25ミリ秒)で繰り返し呼び出し、その都度「現在時刻+先読み時間(例:0.1秒)」より前に鳴るはずの音をすべてWeb Audioへ予約する。Web AudioはAPIに渡した時刻を自分のクロックで正確に守るため、UIスレッドの揺れは先読みバッファに隠れる。
const LOOKAHEAD = 25; // スケジューラー呼び出し間隔(ms)
const SCHEDULE_AHEAD = 0.1; // 先読み時間(秒)
let nextStepTime = 0; // 次のステップが鳴るべき絶対時刻(秒)
let currentStep = 0;
let timerID = null;
let bpm = 120;
function scheduler() {
while (nextStepTime < ctx.currentTime + SCHEDULE_AHEAD) {
scheduleStep(currentStep, nextStepTime);
advanceStep();
}
timerID = setTimeout(scheduler, LOOKAHEAD);
}
function advanceStep() {
const secondsPerStep = (60 / bpm) / 4; // 16分音符1つ分の長さ
nextStepTime += secondsPerStep;
currentStep = (currentStep + 1) % 16;
}
function start() {
// suspended 状態を解除してから再生を開始する
ctx.resume().then(() => {
nextStepTime = ctx.currentTime;
scheduler();
});
}
function stop() {
clearTimeout(timerID);
timerID = null;
}bpmを変数にしておくと、advanceStep内のsecondsPerStep計算にリアルタイムで反映できる。ただし、すでに予約済みの音の時刻は変わらないため、テンポ変更は次の先読みサイクルから有効になる。これは一般的なシーケンサーの動作と同じだ。
start()の先頭でctx.resume()を呼ぶのは、ブラウザの自動再生ポリシーへの対応だ。ユーザーがボタンをクリックしてstart()を呼ぶ構成にしておけば、このPromiseはほぼ即座に解決する。AudioContextをユーザー操作の前に生成してしまった場合もresume()で状態を回復できる。
16ステップのグリッドと四つのトラック
スケジューラーができたら、どのステップでどの音を鳴らすかを管理するデータ構造を作る。最もシンプルな形は「トラック数×ステップ数」の二次元配列で、各セルがtrue/falseを持つ形だ。
const TRACKS = 4;
const STEPS = 16;
// 各トラックの音色設定(type は playTone の第4引数に渡す)
const trackConfig = [
{ freq: 90, type: 'sine', dur: 0.08 }, // キック的な低音
{ freq: 280, type: 'square', dur: 0.05 }, // スネア的な中音
{ freq: 600, type: 'square', dur: 0.03 }, // ハイハット的な高音
{ freq: 180, type: 'sawtooth', dur: 0.06 }, // ベース的な倍音
];
// グリッド:grid[track][step] = true/false
const grid = Array.from({ length: TRACKS }, () => new Array(STEPS).fill(false));
function scheduleStep(step, time) {
for (let t = 0; t < TRACKS; t++) {
if (grid[t][step]) {
const { freq, type, dur } = trackConfig[t];
playTone(freq, time, dur, type); // 波形タイプを第4引数で渡す
}
}
}UIではグリッドのセルをボタンで表し、クリックごとにgrid[t][step]を反転させる。ループは止めずにグリッドだけ書き換えられるため、演奏しながらリアルタイムでパターンを変更できる。
現在再生中のステップをUIに表示したいときは、requestAnimationFrameを使い「currentTimeがどのステップの時刻に近いか」を計算して表示を更新する。スケジューラー側で再生時刻ごとにステップ番号をキューに積んでおき、アニメーションフレームでそのキューを読む方法もある。いずれにせよ、音の予約とUI更新を分離することがポイントだ。
停止・ミュートの設計と細かい注意点
リズムマシンの「停止」には二つの意味がある。一つはループを止めてステップが進まなくなること、もう一つはすでに予約済みの音を取り消すことだ。前者はclearTimeoutで実現できる。後者は少し工夫が要る。
予約済みの音を即座に止めるには、生成したOscillatorNodeへの参照を配列に保持しておき、停止時に各ノードのstop(ctx.currentTime)を呼ぶ方法がある。ただしOscillatorNodeは一度stopを呼んだ後に再度stopを呼ぶとエラーになる実装が多いため、状態フラグで管理するかtry-catchで囲んでおくと安全だ。停止後に再度再生するときはstartから作り直す必要がある。
トラック単位のミュートは、scheduleStep内でgrid[t][step]の確認に加えて「このトラックはミュート中か」というフラグを参照するだけでよい。グリッドのデータは変えずに発音だけ抑制できるため、ミュートを解除したときにパターンがそのまま戻る。
- ループ停止:clearTimeout(timerID)でスケジューラーの繰り返しを止める
- 予約音のキャンセル:生成ノードの参照を保持しstop(ctx.currentTime)を呼ぶ。二重呼び出しに注意
- トラックミュート:scheduleStep内でmute[]フラグを参照し発音をスキップする。グリッドは保持
- 再生再開:ctx.resume()を確認してからscheduler()を呼び直す
「音の遊び」をゲームに育てる視点
ここまでの仕組みで、パターンを組んでループさせるリズムマシンは動く。これを「遊べるもの」にするためには、操作の気持ちよさとフィードバックが重要になる。
パッドをタップしたときにその場で即座に音が出るようにしておくと、グリッドに音を配置する作業自体が楽しくなる。この「即時演奏」と「ループの中での再生」は同じplayTone関数を呼び分けるだけで実現できる。即時演奏ではstartTime=ctx.currentTimeを渡し、スケジューラーからはnextStepTimeを渡す。
ゲーム的なルールを加えるなら、「お手本のリズムを再生して同じパターンを再現する」記憶ゲームや、「決められた小節数で曲を完成させる」パズルが考えられる。パターンのデータは単純な配列なので、正解パターンとの差分を計算するコードを書けばスコアリングもできる。
複数端末でのタイミング同期については、それぞれのAudioContextが独立したクロックを持つため、別々の端末で合わせるには別途サーバーサイドの基準時刻や補正ロジックが必要になる。単一ページで遊ぶリズムマシンならAudioContext.currentTimeだけで安定した動作が期待できる。
アソバースのWAVEは、4トラック・16ステップのループ構成とパッドによる即時演奏を組み合わせたリズムスタジオだ。テンポとスウィングの調整、トラックごとのミュートも備えており、この記事で説明した仕組みを動く形で確かめるのに参考になる。自作する前に一度触れておくと、実装上の判断基準になる。



